DetailedGalleryInfo card
This commit is contained in:
@@ -25,7 +25,6 @@ android {
|
|||||||
versionCode 69
|
versionCode 69
|
||||||
versionName "6.0.0"
|
versionName "6.0.0"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
@@ -52,6 +51,7 @@ android {
|
|||||||
buildConfig true
|
buildConfig true
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
coreLibraryDesugaringEnabled true
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
@@ -64,6 +64,8 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
||||||
|
|
||||||
implementation fileTree(dir: "libs", include: ["*.jar", "*.aar"])
|
implementation fileTree(dir: "libs", include: ["*.jar", "*.aar"])
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
|
||||||
@@ -80,7 +82,7 @@ dependencies {
|
|||||||
implementation "androidx.biometric:biometric:1.1.0"
|
implementation "androidx.biometric:biometric:1.1.0"
|
||||||
implementation "androidx.work:work-runtime-ktx:2.9.0"
|
implementation "androidx.work:work-runtime-ktx:2.9.0"
|
||||||
|
|
||||||
implementation platform("androidx.compose:compose-bom:2024.02.01")
|
implementation platform("androidx.compose:compose-bom:2024.02.02")
|
||||||
|
|
||||||
implementation "androidx.compose.material3:material3"
|
implementation "androidx.compose.material3:material3"
|
||||||
implementation "androidx.compose.material3:material3-window-size-class"
|
implementation "androidx.compose.material3:material3-window-size-class"
|
||||||
@@ -112,7 +114,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation "com.google.android.material:material:1.11.0"
|
implementation "com.google.android.material:material:1.11.0"
|
||||||
|
|
||||||
implementation platform('com.google.firebase:firebase-bom:32.7.0')
|
implementation platform('com.google.firebase:firebase-bom:32.7.4')
|
||||||
implementation "com.google.firebase:firebase-analytics-ktx"
|
implementation "com.google.firebase:firebase-analytics-ktx"
|
||||||
implementation "com.google.firebase:firebase-crashlytics-ktx"
|
implementation "com.google.firebase:firebase-crashlytics-ktx"
|
||||||
implementation "com.google.firebase:firebase-perf-ktx"
|
implementation "com.google.firebase:firebase-perf-ktx"
|
||||||
|
|||||||
5
app/proguard-rules.pro
vendored
5
app/proguard-rules.pro
vendored
@@ -31,7 +31,4 @@
|
|||||||
-keepclasseswithmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
|
-keepclasseswithmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
|
||||||
kotlinx.serialization.KSerializer serializer(...);
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
}
|
}
|
||||||
-keep class xyz.quaver.pupil.ui.fragment.ManageFavoritesFragment
|
-keep class xyz.quaver.pupil.** { *; }
|
||||||
-keep class xyz.quaver.pupil.ui.fragment.ManageStorageFragment
|
|
||||||
-keep class xyz.quaver.pupil.** { *; }
|
|
||||||
-keep class app.cash.zipline.** { *; }
|
|
||||||
@@ -30,6 +30,8 @@ import android.util.Log
|
|||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.ImageLoaderFactory
|
||||||
import com.facebook.imagepipeline.backends.okhttp3.OkHttpImagePipelineConfigFactory
|
import com.facebook.imagepipeline.backends.okhttp3.OkHttpImagePipelineConfigFactory
|
||||||
import com.github.piasy.biv.BigImageViewer
|
import com.github.piasy.biv.BigImageViewer
|
||||||
import com.github.piasy.biv.loader.fresco.FrescoImageLoader
|
import com.github.piasy.biv.loader.fresco.FrescoImageLoader
|
||||||
@@ -44,6 +46,7 @@ import okhttp3.Interceptor
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import xyz.quaver.io.FileX
|
import xyz.quaver.io.FileX
|
||||||
|
import xyz.quaver.pupil.networking.SSLSettings
|
||||||
import xyz.quaver.pupil.types.Tag
|
import xyz.quaver.pupil.types.Tag
|
||||||
import xyz.quaver.pupil.util.*
|
import xyz.quaver.pupil.util.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -74,7 +77,7 @@ val client: OkHttpClient
|
|||||||
clientHolder = it
|
clientHolder = it
|
||||||
}
|
}
|
||||||
|
|
||||||
class Pupil : Application() {
|
class Pupil : Application(), ImageLoaderFactory {
|
||||||
companion object {
|
companion object {
|
||||||
lateinit var instance: Pupil
|
lateinit var instance: Pupil
|
||||||
private set
|
private set
|
||||||
@@ -207,4 +210,13 @@ class Pupil : Application() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun newImageLoader() = ImageLoader
|
||||||
|
.Builder(this)
|
||||||
|
.okHttpClient {
|
||||||
|
OkHttpClient
|
||||||
|
.Builder()
|
||||||
|
.sslSocketFactory(SSLSettings.sslContext!!.socketFactory, SSLSettings.trustManager!!)
|
||||||
|
.build()
|
||||||
|
}.build()
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -10,13 +10,18 @@ import io.ktor.client.statement.bodyAsText
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.Clock.System.now
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import xyz.quaver.pupil.hitomi.max_node_size
|
import xyz.quaver.pupil.hitomi.max_node_size
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import java.nio.IntBuffer
|
import java.nio.IntBuffer
|
||||||
import java.nio.charset.Charset
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
const val domain = "ltn.hitomi.la"
|
const val domain = "ltn.hitomi.la"
|
||||||
const val galleryBlockExtension = ".html"
|
const val galleryBlockExtension = ".html"
|
||||||
@@ -51,6 +56,54 @@ private val json = Json {
|
|||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ImagePathResolver(ggjs: String) {
|
||||||
|
private val defaultPrefix: Int = Regex("var o = (\\d)").find(ggjs)!!.groupValues[1].toInt()
|
||||||
|
private val prefixMap: Map<Int, Int> = buildMap {
|
||||||
|
val o = Regex("o = (\\d); break;").find(ggjs)!!.groupValues[1].toInt()
|
||||||
|
|
||||||
|
Regex("case (\\d+):").findAll(ggjs).forEach {
|
||||||
|
val case = it.groupValues[1].toInt()
|
||||||
|
put(case, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val imageBaseDir: String = Regex("b: '(.+)'").find(ggjs)!!.groupValues[1]
|
||||||
|
|
||||||
|
fun decodeSubdomain(hash: String, thumbnail: Boolean): String {
|
||||||
|
val key = (hash.last() + hash.dropLast(1).takeLast(2)).toInt(16)
|
||||||
|
val base = if (thumbnail) "tn" else "a"
|
||||||
|
|
||||||
|
return "${'a' + (prefixMap[key] ?: defaultPrefix)}$base"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeImagePath(hash: String, thumbnail: Boolean): String {
|
||||||
|
val key = hash.last() to hash.dropLast(1).takeLast(2)
|
||||||
|
|
||||||
|
return if (thumbnail) {
|
||||||
|
"${key.first}/${key.second}/$hash"
|
||||||
|
} else {
|
||||||
|
"$imageBaseDir/${(key.first + key.second).toInt(16)}/$hash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpirableEntry<T>(
|
||||||
|
private val expiryDuration: Duration,
|
||||||
|
private val action: suspend () -> T
|
||||||
|
) {
|
||||||
|
private var value: T? = null
|
||||||
|
private var expiresAt: Instant = now()
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
suspend fun getValue(): T = mutex.withLock {
|
||||||
|
value?.let { if (expiresAt > now()) value else null } ?: action().also {
|
||||||
|
expiresAt = now() + expiryDuration
|
||||||
|
value = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
object HitomiHttpClient {
|
object HitomiHttpClient {
|
||||||
private val httpClient = HttpClient(OkHttp) {
|
private val httpClient = HttpClient(OkHttp) {
|
||||||
engine {
|
engine {
|
||||||
@@ -60,17 +113,12 @@ object HitomiHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var _tagIndexVersion: String? = null
|
private var imagePathResolver = ExpirableEntry(1.minutes) {
|
||||||
private suspend fun getTagIndexVersion(): String =
|
ImagePathResolver(httpClient.get("https://ltn.hitomi.la/gg.js").bodyAsText())
|
||||||
_tagIndexVersion ?: getIndexVersion("tagindex").also {
|
}
|
||||||
_tagIndexVersion = it
|
|
||||||
}
|
|
||||||
|
|
||||||
private var _galleriesIndexVersion: String? = null
|
private val tagIndexVersion = ExpirableEntry(1.minutes) { getIndexVersion("tagindex") }
|
||||||
private suspend fun getGalleriesIndexVersion(): String =
|
private val galleriesIndexVersion = ExpirableEntry(1.minutes) { getIndexVersion("galleriesindex") }
|
||||||
_galleriesIndexVersion ?: getIndexVersion("galleriesindex").also {
|
|
||||||
_galleriesIndexVersion = it
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getIndexVersion(name: String): String = withContext(Dispatchers.IO) {
|
private suspend fun getIndexVersion(name: String): String = withContext(Dispatchers.IO) {
|
||||||
httpClient.get("https://$domain/$name/version?_=${System.currentTimeMillis()}").bodyAsText()
|
httpClient.get("https://$domain/$name/version?_=${System.currentTimeMillis()}").bodyAsText()
|
||||||
@@ -90,10 +138,10 @@ object HitomiHttpClient {
|
|||||||
|
|
||||||
private suspend fun getNodeAtAddress(field: String, address: Long): Node {
|
private suspend fun getNodeAtAddress(field: String, address: Long): Node {
|
||||||
val url = when (field) {
|
val url = when (field) {
|
||||||
"galleries" -> "https://$domain/$galleriesIndexDir/galleries.${getGalleriesIndexVersion()}.index"
|
"galleries" -> "https://$domain/$galleriesIndexDir/galleries.${galleriesIndexVersion.getValue()}.index"
|
||||||
"languages" -> "https://$domain/$galleriesIndexDir/languages.${getGalleriesIndexVersion()}.index"
|
"languages" -> "https://$domain/$galleriesIndexDir/languages.${galleriesIndexVersion.getValue()}.index"
|
||||||
"nozomiurl" -> "https://$domain/$galleriesIndexDir/nozomiurl.${getGalleriesIndexVersion()}.index"
|
"nozomiurl" -> "https://$domain/$galleriesIndexDir/nozomiurl.${galleriesIndexVersion.getValue()}.index"
|
||||||
else -> "https://$domain/$indexDir/$field.${getTagIndexVersion()}.index"
|
else -> "https://$domain/$indexDir/$field.${HitomiHttpClient.tagIndexVersion.getValue()}.index"
|
||||||
}
|
}
|
||||||
|
|
||||||
return Node.decodeNode(
|
return Node.decodeNode(
|
||||||
@@ -123,7 +171,7 @@ object HitomiHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getGalleryIDsFromData(offset: Long, length: Int): IntBuffer {
|
private suspend fun getGalleryIDsFromData(offset: Long, length: Int): IntBuffer {
|
||||||
val url = "https://$domain/$galleriesIndexDir/galleries.${getGalleriesIndexVersion()}.data"
|
val url = "https://$domain/$galleriesIndexDir/galleries.${galleriesIndexVersion.getValue()}.data"
|
||||||
if (length > 100000000 || length <= 0) {
|
if (length > 100000000 || length <= 0) {
|
||||||
error("length $length is too long")
|
error("length $length is too long")
|
||||||
}
|
}
|
||||||
@@ -132,7 +180,7 @@ object HitomiHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getSuggestionsFromData(field: String, data: Node.Data): List<Suggestion> {
|
private suspend fun getSuggestionsFromData(field: String, data: Node.Data): List<Suggestion> {
|
||||||
val url = "https://$domain/$indexDir/$field.${getTagIndexVersion()}.data"
|
val url = "https://$domain/$indexDir/$field.${tagIndexVersion.getValue()}.data"
|
||||||
val (offset, length) = data
|
val (offset, length) = data
|
||||||
|
|
||||||
check(data.length in 1..10000) { "Invalid length ${data.length}" }
|
check(data.length in 1..10000) { "Invalid length ${data.length}" }
|
||||||
@@ -214,6 +262,36 @@ object HitomiHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getImageURL(galleryFile: GalleryFile, thumbnail: Boolean = false): List<String> = buildList {
|
||||||
|
val imagePathResolver = imagePathResolver.getValue()
|
||||||
|
|
||||||
|
listOf("webp", "avif", "jxl").forEach { type ->
|
||||||
|
val available = when {
|
||||||
|
thumbnail && type != "jxl" -> true
|
||||||
|
type == "webp" -> galleryFile.hasWebP != 0
|
||||||
|
type == "avif" -> galleryFile.hasAVIF != 0
|
||||||
|
!thumbnail && type == "jxl" -> galleryFile.hasJXL != 0
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!available) return@forEach
|
||||||
|
|
||||||
|
val url = buildString {
|
||||||
|
append("https://")
|
||||||
|
append(imagePathResolver.decodeSubdomain(galleryFile.hash, thumbnail))
|
||||||
|
append(".hitomi.la/")
|
||||||
|
append(type)
|
||||||
|
if (thumbnail) append("bigtn")
|
||||||
|
append('/')
|
||||||
|
append(imagePathResolver.decodeImagePath(galleryFile.hash, thumbnail))
|
||||||
|
append('.')
|
||||||
|
append(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun search(query: SearchQuery?): Result<Set<Int>> = runCatching {
|
suspend fun search(query: SearchQuery?): Result<Set<Int>> = runCatching {
|
||||||
when (query) {
|
when (query) {
|
||||||
is SearchQuery.Tag -> getGalleryIDsForQuery(query).toSet()
|
is SearchQuery.Tag -> getGalleryIDsForQuery(query).toSet()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
@@ -17,24 +18,29 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.StarOutline
|
import androidx.compose.material.icons.filled.StarOutline
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.SubcomposeAsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
import xyz.quaver.pupil.R
|
import xyz.quaver.pupil.R
|
||||||
import xyz.quaver.pupil.networking.Artist
|
import xyz.quaver.pupil.networking.Artist
|
||||||
import xyz.quaver.pupil.networking.Character
|
import xyz.quaver.pupil.networking.Character
|
||||||
@@ -42,6 +48,7 @@ import xyz.quaver.pupil.networking.GalleryFile
|
|||||||
import xyz.quaver.pupil.networking.GalleryInfo
|
import xyz.quaver.pupil.networking.GalleryInfo
|
||||||
import xyz.quaver.pupil.networking.GalleryTag
|
import xyz.quaver.pupil.networking.GalleryTag
|
||||||
import xyz.quaver.pupil.networking.Group
|
import xyz.quaver.pupil.networking.Group
|
||||||
|
import xyz.quaver.pupil.networking.HitomiHttpClient
|
||||||
import xyz.quaver.pupil.networking.Language
|
import xyz.quaver.pupil.networking.Language
|
||||||
import xyz.quaver.pupil.networking.Series
|
import xyz.quaver.pupil.networking.Series
|
||||||
import xyz.quaver.pupil.networking.joinToCapitalizedString
|
import xyz.quaver.pupil.networking.joinToCapitalizedString
|
||||||
@@ -239,63 +246,142 @@ fun TagGroup(tags: List<GalleryTag>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DetailedGalleryInfoHeader(galleryInfo: GalleryInfo, thumbnailUrl: String?) {
|
||||||
|
val thumbnailFile = galleryInfo.files.firstOrNull()
|
||||||
|
if (thumbnailFile?.let { it.width > it.height } == true) {
|
||||||
|
Column {
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(thumbnailUrl)
|
||||||
|
.setHeader("Referer", "https://hitomi.la/")
|
||||||
|
.build(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(thumbnailFile.let { it.width / it.height.toFloat() })
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
loading = { CircularProgressIndicator() },
|
||||||
|
error = { Image(painter= painterResource(R.drawable.thumbnail), contentDescription = null) },
|
||||||
|
contentDescription = "Thumbnail"
|
||||||
|
)
|
||||||
|
Text(galleryInfo.title, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
val artistsAndGroups = buildString {
|
||||||
|
if (!galleryInfo.artists.isNullOrEmpty())
|
||||||
|
append(galleryInfo.artists.joinToCapitalizedString())
|
||||||
|
|
||||||
|
if (!galleryInfo.groups.isNullOrEmpty()) {
|
||||||
|
if (this.isNotEmpty()) append(' ')
|
||||||
|
append('(')
|
||||||
|
append(galleryInfo.groups.joinToCapitalizedString())
|
||||||
|
append(')')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
artistsAndGroups,
|
||||||
|
style = MaterialTheme.typography.labelLarge
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (galleryInfo.series?.isNotEmpty() == true)
|
||||||
|
Text(
|
||||||
|
"Series: ${galleryInfo.series.joinToCapitalizedString()}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Type: ${galleryInfo.type}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
languageMap[galleryInfo.language]?.let {
|
||||||
|
Text(
|
||||||
|
"Language: $it",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(thumbnailUrl)
|
||||||
|
.setHeader("Referer", "https://hitomi.la/")
|
||||||
|
.build(),
|
||||||
|
modifier = Modifier
|
||||||
|
.height(200.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
loading = { CircularProgressIndicator() },
|
||||||
|
error = { Image(painter= painterResource(R.drawable.thumbnail), contentDescription = null) },
|
||||||
|
contentDescription = "Thumbnail"
|
||||||
|
)
|
||||||
|
Column(Modifier.heightIn(min = 200.dp)) {
|
||||||
|
Text(galleryInfo.title, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
val artistsAndGroups = buildString {
|
||||||
|
if (!galleryInfo.artists.isNullOrEmpty())
|
||||||
|
append(galleryInfo.artists.joinToCapitalizedString())
|
||||||
|
|
||||||
|
if (!galleryInfo.groups.isNullOrEmpty()) {
|
||||||
|
if (this.isNotEmpty()) append(' ')
|
||||||
|
append('(')
|
||||||
|
append(galleryInfo.groups.joinToCapitalizedString())
|
||||||
|
append(')')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
artistsAndGroups,
|
||||||
|
style = MaterialTheme.typography.labelLarge
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(min = 8.dp))
|
||||||
|
|
||||||
|
if (galleryInfo.series?.isNotEmpty() == true)
|
||||||
|
Text(
|
||||||
|
"Series: ${galleryInfo.series.joinToCapitalizedString()}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Type: ${galleryInfo.type}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
languageMap[galleryInfo.language]?.let {
|
||||||
|
Text(
|
||||||
|
"Language: $it",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun DetailedGalleryInfo(
|
fun DetailedGalleryInfo(
|
||||||
@PreviewParameter(GalleryInfoProvider::class) galleryInfo: GalleryInfo,
|
@PreviewParameter(GalleryInfoProvider::class) galleryInfo: GalleryInfo,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
var thumbnailUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(galleryInfo) {
|
||||||
|
thumbnailUrl = galleryInfo.files.firstOrNull()?.let {
|
||||||
|
HitomiHttpClient.getImageURL(it, true).firstOrNull()
|
||||||
|
} ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
Card(modifier) {
|
Card(modifier) {
|
||||||
Column(Modifier.padding(8.dp)) {
|
Column(Modifier.padding(8.dp)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
DetailedGalleryInfoHeader(galleryInfo, thumbnailUrl)
|
||||||
Image(
|
|
||||||
modifier = Modifier.height(200.dp),
|
|
||||||
painter = painterResource(R.drawable.thumbnail),
|
|
||||||
contentDescription = "Icon"
|
|
||||||
)
|
|
||||||
Column(Modifier.heightIn(min = 200.dp)) {
|
|
||||||
Text(galleryInfo.title, style = MaterialTheme.typography.headlineSmall)
|
|
||||||
val artistsAndGroups = buildString {
|
|
||||||
if (!galleryInfo.artists.isNullOrEmpty())
|
|
||||||
append(galleryInfo.artists.joinToCapitalizedString())
|
|
||||||
|
|
||||||
if (!galleryInfo.groups.isNullOrEmpty()) {
|
|
||||||
if (this.isNotEmpty()) append(' ')
|
|
||||||
append('(')
|
|
||||||
append(galleryInfo.groups.joinToCapitalizedString())
|
|
||||||
append(')')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
artistsAndGroups,
|
|
||||||
style = MaterialTheme.typography.labelLarge
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(
|
|
||||||
Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.heightIn(min = 8.dp))
|
|
||||||
|
|
||||||
if (galleryInfo.series?.isNotEmpty() == true)
|
|
||||||
Text(
|
|
||||||
"Series: ${galleryInfo.series.joinToCapitalizedString()}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
"Type: ${galleryInfo.type}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
|
|
||||||
languageMap[galleryInfo.language]?.let {
|
|
||||||
Text(
|
|
||||||
"Language: $it",
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (galleryInfo.tags?.isNotEmpty() == true) {
|
if (galleryInfo.tags?.isNotEmpty() == true) {
|
||||||
TagGroup(galleryInfo.tags)
|
TagGroup(galleryInfo.tags)
|
||||||
@@ -303,7 +389,10 @@ fun DetailedGalleryInfo(
|
|||||||
|
|
||||||
HorizontalDivider(Modifier.padding(4.dp))
|
HorizontalDivider(Modifier.padding(4.dp))
|
||||||
|
|
||||||
Box(Modifier.fillMaxWidth().padding(4.dp)) {
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(4.dp)) {
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.align(Alignment.CenterStart),
|
modifier = Modifier.align(Alignment.CenterStart),
|
||||||
text = galleryInfo.id,
|
text = galleryInfo.id,
|
||||||
@@ -315,7 +404,9 @@ fun DetailedGalleryInfo(
|
|||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium
|
||||||
)
|
)
|
||||||
Icon(
|
Icon(
|
||||||
modifier = Modifier.align(Alignment.CenterEnd).size(32.dp),
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterEnd)
|
||||||
|
.size(32.dp),
|
||||||
imageVector = Icons.Default.StarOutline,
|
imageVector = Icons.Default.StarOutline,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Yellow500
|
tint = Yellow500
|
||||||
|
|||||||
Reference in New Issue
Block a user