DetailedGalleryInfo card

This commit is contained in:
tom5079
2024-03-24 17:50:31 -07:00
parent d1381b8700
commit e648b6dfee
5 changed files with 257 additions and 77 deletions

View File

@@ -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"

View File

@@ -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.** { *; }

View File

@@ -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()
} }

View File

@@ -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()

View File

@@ -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