Shows Image

This commit is contained in:
tom5079
2021-12-01 17:18:19 +09:00
parent 70452ba7a6
commit 6c13a624a9
10 changed files with 326 additions and 41 deletions

View File

@@ -132,7 +132,7 @@ dependencies {
implementation("xyz.quaver:libpupil:2.1.11")
implementation("xyz.quaver:documentfilex:0.7.1")
implementation("xyz.quaver:floatingsearchview:1.1.7")
implementation("xyz.quaver:subsampledimage:0.0.1-alpha01-SNAPSHOT")
implementation("xyz.quaver:subsampledimage:0.0.1-alpha09-SNAPSHOT")
implementation("org.kodein.log:kodein-log:0.11.1")
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.7")

View File

@@ -58,6 +58,7 @@ class Pupil : Application(), DIAware {
import(sourceModule)
bind { singleton { DownloadManager(applicationContext) } }
bind { singleton { NetworkCache(applicationContext) } }
bind { singleton {
HttpClient(OkHttp) {

View File

@@ -21,6 +21,7 @@ package xyz.quaver.pupil.sources
import android.app.Application
import android.os.Parcelable
import androidx.compose.runtime.Composable
import io.ktor.http.*
import kotlinx.coroutines.channels.Channel
import kotlinx.parcelize.Parcelize
import org.kodein.di.*
@@ -61,7 +62,7 @@ abstract class Source {
@Composable
open fun SearchResult(itemInfo: ItemInfo, onEvent: ((SearchResultEvent) -> Unit)? = null) { }
open fun getHeadersForImage(itemID: String, url: String): Map<String, String> = emptyMap()
open fun getHeadersBuilderForImage(itemID: String, url: String): HeadersBuilder.() -> Unit = { }
open fun onSuggestionBind(binding: SearchSuggestionItemBinding, item: SearchSuggestion) {
binding.leftIcon.setImageResource(R.drawable.tag)

View File

@@ -190,7 +190,7 @@ class Hitomi(app: Application) : Source(), DIAware {
val reader = getGalleryInfo(galleryID)
return reader.files.map {
imageUrlFromImage(galleryID, it, true)
imageUrlFromImage(galleryID, it, false)
}
}
@@ -225,9 +225,9 @@ class Hitomi(app: Application) : Source(), DIAware {
FullSearchResult(itemInfo = itemInfo)
}
override fun getHeadersForImage(itemID: String, url: String) = mapOf(
"Referer" to getReferer(itemID.toInt())
)
override fun getHeadersBuilderForImage(itemID: String, url: String): HeadersBuilder.() -> Unit = {
append("Referer", getReferer(itemID.toInt()))
}
override fun onSuggestionBind(binding: SearchSuggestionItemBinding, item: SearchSuggestion) {
item as TagSuggestion

View File

@@ -24,40 +24,44 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.runtime.getValue
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import coil.annotation.ExperimentalCoilApi
import coil.compose.rememberImagePainter
import coil.request.ImageRequest
import coil.transform.BlurTransformation
import com.google.accompanist.appcompattheme.AppCompatTheme
import io.ktor.http.*
import okhttp3.Headers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.log.LoggerFactory
import org.kodein.log.newLogger
import xyz.quaver.graphics.subsampledimage.SubSampledImage
import xyz.quaver.graphics.subsampledimage.*
import xyz.quaver.io.FileX
import xyz.quaver.pupil.R
import xyz.quaver.pupil.ui.composable.FloatingActionButtonState
import xyz.quaver.pupil.ui.composable.MultipleFloatingActionButton
import xyz.quaver.pupil.ui.composable.SubFabItem
import xyz.quaver.pupil.ui.viewmodel.ReaderViewModel
import xyz.quaver.pupil.util.FileXImageSource
import kotlin.math.abs
class ReaderActivity : ComponentActivity(), DIAware {
override val di by closestDI()
@@ -78,10 +82,31 @@ class ReaderActivity : ComponentActivity(), DIAware {
val isFullscreen by model.isFullscreen.observeAsState(false)
val title by model.title.observeAsState(stringResource(R.string.reader_loading))
val sourceIcon by model.sourceIcon.observeAsState()
val images by model.images.observeAsState(emptyList())
val source by model.sourceInstance.observeAsState()
val imageSources = remember { mutableStateListOf<ImageSource?>() }
val imageHeights = remember { mutableStateListOf<Float?>() }
val states = remember { mutableStateListOf<SubSampledImageState>() }
logger.debug { "target: ${R.drawable.hitomi} value: $sourceIcon" }
LaunchedEffect(model.totalProgress) {
if (imageSources.isEmpty() && model.imageList.isNotEmpty())
imageSources.addAll(List(model.imageList.size) { null })
if (states.isEmpty() && model.imageList.isNotEmpty())
states.addAll(List(model.imageList.size) { SubSampledImageState(ScaleTypes.FIT_WIDTH, Bounds.FORCE_OVERLAP_OR_CENTER) })
if (imageHeights.isEmpty() && model.imageList.isNotEmpty())
imageHeights.addAll(List(model.imageList.size) { null })
model.imageList.forEachIndexed { i, image ->
if (imageSources[i] == null && image != null)
CoroutineScope(Dispatchers.Default).launch {
imageSources[i] = kotlin.runCatching {
FileXImageSource(FileX(this@ReaderActivity, image))
}.onFailure {
model.error(i)
}.getOrNull()
}
}
}
WindowInsetsControllerCompat(window, window.decorView).run {
if (isFullscreen) {
@@ -121,7 +146,7 @@ class ReaderActivity : ComponentActivity(), DIAware {
icon = Icons.Default.Fullscreen,
label = stringResource(id = R.string.reader_fab_fullscreen)
) {
model.isFullscreen.postValue(!isFullscreen)
model.isFullscreen.postValue(true)
}
),
targetState = isFABExpanded,
@@ -132,14 +157,57 @@ class ReaderActivity : ComponentActivity(), DIAware {
}
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(32.dp)
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(images) { image ->
SubSampledImage(
modifier = Modifier.fillMaxWidth().heightIn(128.dp, 1000.dp)
)
itemsIndexed(imageSources) { i, imageSource ->
LaunchedEffect(states[i].canvasSize, states[i].imageSize) {
if (imageHeights.isNotEmpty() && imageHeights[i] == null)
states[i].canvasSize?.let { canvasSize ->
states[i].imageSize?.let { imageSize ->
imageHeights[i] = imageSize.height * canvasSize.width / imageSize.width
} }
}
Box(
Modifier
.height(
imageHeights
.getOrNull(i)
?.let { with(LocalDensity.current) { it.toDp() } }
?: 500.dp)
.fillMaxWidth()
.border(1.dp, Color.Gray),
contentAlignment = Alignment.Center
) {
if (imageSource == null)
model.progressList.getOrNull(i)?.let { progress ->
if (progress < 0f)
Icon(Icons.Filled.BrokenImage, null)
else
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
LinearProgressIndicator(progress)
Text((i + 1).toString())
}
}
else
SubSampledImage(
modifier = Modifier.fillMaxSize(),
imageSource = imageSource,
state = states[i]
)
}
}
}
if (model.totalProgress != model.imageCount)
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
progress = model.progressList.map { abs(it) }.sum() / model.progressList.size,
color = colorResource(id = R.color.colorAccent)
)
}
}
}

View File

@@ -1,6 +1,8 @@
package xyz.quaver.pupil.ui.composable
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -17,6 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -72,16 +75,17 @@ fun MiniFloatingActionButton(
}
}
FloatingActionButton(
modifier = Modifier
.size(40.dp)
.scale(buttonScale),
onClick = { onClick?.invoke(item) },
elevation = elevation,
interactionSource = interactionSource
) {
Icon(item.icon, contentDescription = null)
}
if (buttonScale > 0f)
FloatingActionButton(
modifier = Modifier
.size(40.dp)
.scale(buttonScale),
onClick = { onClick?.invoke(item) },
elevation = elevation,
interactionSource = interactionSource
) {
Icon(item.icon, contentDescription = null)
}
}
}

View File

@@ -21,8 +21,17 @@ package xyz.quaver.pupil.ui.viewmodel
import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.kodein.di.DIAware
import org.kodein.di.android.x.closestDI
import org.kodein.di.direct
@@ -33,6 +42,7 @@ import xyz.quaver.pupil.db.AppDatabase
import xyz.quaver.pupil.db.Bookmark
import xyz.quaver.pupil.db.History
import xyz.quaver.pupil.sources.Source
import xyz.quaver.pupil.util.NetworkCache
import xyz.quaver.pupil.util.source
@Suppress("UNCHECKED_CAST")
@@ -40,6 +50,8 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
override val di by closestDI()
private val cache: NetworkCache by instance()
private val logger = newLogger(LoggerFactory.default)
val isFullscreen = MutableLiveData(false)
@@ -58,8 +70,14 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
private val _title = MutableLiveData<String>()
val title = _title as LiveData<String>
private val _images = MutableLiveData<List<String>>()
val images: LiveData<List<String>> = _images
private val totalProgressMutex = Mutex()
var totalProgress by mutableStateOf(0)
private set
var imageCount by mutableStateOf(0)
private set
val imageList = mutableStateListOf<Uri?>()
val progressList = mutableStateListOf<Float>()
val isBookmarked = Transformations.switchMap(MediatorLiveData<Pair<Source, String>>().apply {
addSource(source) { source -> itemID.value?.let { itemID -> source to itemID } }
@@ -119,12 +137,66 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
}
viewModelScope.launch {
_images.postValue(withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) {
source.images(itemID)
})
}.let { images ->
imageCount = images.size
progressList.addAll(List(imageCount) { 0f })
imageList.addAll(List(imageCount) { null })
images.forEachIndexed { index, image ->
when (val scheme = image.takeWhile { it != ':' }) {
"http", "https" -> {
val file = cache.load {
url(image)
headers(source.getHeadersBuilderForImage(itemID, image))
}
val channel = cache.channels[image] ?: error("Channel is null")
if (channel.isClosedForReceive) {
imageList[index] = Uri.fromFile(file)
totalProgressMutex.withLock {
totalProgress++
}
} else {
channel.invokeOnClose { e ->
viewModelScope.launch {
if (e == null) {
imageList[index] = Uri.fromFile(file)
totalProgressMutex.withLock {
totalProgress++
}
} else {
TODO("Handle error")
}
}
}
launch {
kotlin.runCatching {
for (progress in channel) {
progressList[index] = progress
}
}
}
}
}
"content" -> {
progressList[index] = 1f
}
else -> throw IllegalArgumentException("Expected URL scheme 'http(s)' or 'content' but was '$scheme'")
}
}
}
}
}
fun error(index: Int) {
progressList[index] = -1f
}
fun toggleBookmark() {
val bookmark = source.value?.let { source -> itemID.value?.let { itemID -> Bookmark(source, itemID) } } ?: return

View File

@@ -0,0 +1,106 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util
import android.content.Context
import com.google.firebase.crashlytics.FirebaseCrashlytics
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.util.*
import io.ktor.utils.io.*
import io.ktor.utils.io.core.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.di.instance
import xyz.quaver.hitomi.sha256
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import kotlin.text.toByteArray
class NetworkCache(context: Context) : DIAware {
override val di by closestDI(context)
private val client: HttpClient by instance()
private val cacheDir = context.cacheDir
private val _channels = ConcurrentHashMap<String, Channel<Float>>()
val channels = _channels as Map<String, Channel<Float>>
private val requests = mutableMapOf<String, Job>()
private val networkScope = CoroutineScope(Executors.newFixedThreadPool(4).asCoroutineDispatcher())
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun load(requestBuilder: HttpRequestBuilder.() -> Unit): File = coroutineScope {
val request = HttpRequestBuilder().apply(requestBuilder)
val url = request.url.buildString()
val hash = sha256(url.toByteArray()).joinToString("") { "%02x".format(it) }
val file = File(cacheDir, "$hash.${url.takeLastWhile { it != '.' }}")
val progressChannel = if (_channels[url]?.isClosedForSend == false)
_channels[url]!!
else
Channel<Float>(1, BufferOverflow.DROP_OLDEST).also { _channels[url] = it }
if (file.exists())
progressChannel.close()
else
requests[url] = networkScope.launch {
kotlin.runCatching {
file.createNewFile()
client.request<HttpStatement>(request).execute { httpResponse ->
val responseChannel: ByteReadChannel = httpResponse.receive()
val contentLength = httpResponse.contentLength() ?: -1
var readBytes = 0f
file.outputStream().use { outputStream ->
while (!responseChannel.isClosedForRead) {
val packet = responseChannel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
while (!packet.isEmpty) {
val bytes = packet.readBytes()
outputStream.write(bytes)
readBytes += bytes.size
progressChannel.trySend(readBytes / contentLength)
}
}
}
progressChannel.close()
}
}.onFailure {
file.delete()
FirebaseCrashlytics.getInstance().recordException(it)
progressChannel.close(it)
}
}
return@coroutineScope file
}
}

View File

@@ -19,17 +19,33 @@
package xyz.quaver.pupil.util
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.view.MenuItem
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toAndroidRect
import androidx.lifecycle.MutableLiveData
import kotlinx.serialization.json.*
import org.kodein.di.DIAware
import org.kodein.di.DirectDIAware
import org.kodein.di.direct
import org.kodein.di.instance
import xyz.quaver.graphics.subsampledimage.ImageSource
import xyz.quaver.graphics.subsampledimage.newBitmapRegionDecoder
import xyz.quaver.io.FileX
import xyz.quaver.io.util.inputStream
import xyz.quaver.pupil.db.AppDatabase
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.SourceEntries
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.*
@@ -136,4 +152,20 @@ fun View.hide() {
fun View.show() {
visibility = View.VISIBLE
}
class FileXImageSource(file: FileX): ImageSource {
private val decoder = newBitmapRegionDecoder(file.inputStream()!!)
override val imageSize by lazy { Size(decoder.width.toFloat(), decoder.height.toFloat()) }
override fun decodeRegion(region: Rect, sampleSize: Int): ImageBitmap =
decoder.decodeRegion(region.toAndroidRect(), BitmapFactory.Options().apply {
inSampleSize = sampleSize
}).asImageBitmap()
}
@Composable
fun rememberFileXImageSource(file: FileX) = remember {
FileXImageSource(file)
}

View File

@@ -13,7 +13,7 @@ buildscript {
classpath("com.google.gms:google-services:4.3.10")
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
classpath("com.google.firebase:firebase-crashlytics-gradle:2.7.1")
classpath("com.google.firebase:firebase-crashlytics-gradle:2.8.0")
classpath("com.google.firebase:perf-plugin:1.4.0")
classpath("com.google.android.gms:oss-licenses-plugin:0.10.4")
}
@@ -23,6 +23,7 @@ allprojects {
repositories {
google()
mavenCentral()
mavenLocal()
maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots") }
maven { url = uri("https://jitpack.io") }
}