Merge branch 'devel' into feature/nextgen
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.download.domain
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.webkit.MimeTypeMap
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
@@ -20,6 +19,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.download.ui.service.PausingHandle
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.domain.CbzMangaOutput
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
@@ -29,10 +29,9 @@ import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.utils.ext.referer
|
||||
import org.koitharu.kotatsu.utils.ext.waitForNetwork
|
||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
|
||||
|
||||
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
||||
private const val MAX_FAILSAFE_ATTEMPTS = 2
|
||||
private const val DOWNLOAD_ERROR_DELAY = 500L
|
||||
private const val SLOWDOWN_DELAY = 200L
|
||||
|
||||
@@ -47,9 +46,6 @@ class DownloadManager @AssistedInject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) {
|
||||
|
||||
private val connectivityManager = context.getSystemService(
|
||||
Context.CONNECTIVITY_SERVICE,
|
||||
) as ConnectivityManager
|
||||
private val coverWidth = context.resources.getDimensionPixelSize(
|
||||
androidx.core.R.dimen.compat_notification_large_icon_max_width,
|
||||
)
|
||||
@@ -62,18 +58,20 @@ class DownloadManager @AssistedInject constructor(
|
||||
manga: Manga,
|
||||
chaptersIds: LongArray?,
|
||||
startId: Int,
|
||||
): ProgressJob<DownloadState> {
|
||||
): PausingProgressJob<DownloadState> {
|
||||
val stateFlow = MutableStateFlow<DownloadState>(
|
||||
DownloadState.Queued(startId = startId, manga = manga, cover = null),
|
||||
)
|
||||
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, startId)
|
||||
return ProgressJob(job, stateFlow)
|
||||
val pausingHandle = PausingHandle()
|
||||
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId)
|
||||
return PausingProgressJob(job, stateFlow, pausingHandle)
|
||||
}
|
||||
|
||||
private fun downloadMangaImpl(
|
||||
manga: Manga,
|
||||
chaptersIds: LongArray?,
|
||||
outState: MutableStateFlow<DownloadState>,
|
||||
pausingHandle: PausingHandle,
|
||||
startId: Int,
|
||||
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
@@ -113,36 +111,24 @@ class DownloadManager @AssistedInject constructor(
|
||||
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
|
||||
}
|
||||
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
||||
val pages = repo.getPages(chapter)
|
||||
val pages = runFailsafe(outState, pausingHandle) {
|
||||
repo.getPages(chapter)
|
||||
}
|
||||
for ((pageIndex, page) in pages.withIndex()) {
|
||||
var retryCounter = 0
|
||||
failsafe@ while (true) {
|
||||
try {
|
||||
val url = repo.getPageUrl(page)
|
||||
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
|
||||
output.addPage(
|
||||
chapter = chapter,
|
||||
file = file,
|
||||
pageNumber = pageIndex,
|
||||
ext = MimeTypeMap.getFileExtensionFromUrl(url),
|
||||
)
|
||||
break@failsafe
|
||||
} catch (e: IOException) {
|
||||
if (retryCounter < MAX_DOWNLOAD_ATTEMPTS) {
|
||||
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
|
||||
delay(DOWNLOAD_ERROR_DELAY)
|
||||
connectivityManager.waitForNetwork()
|
||||
retryCounter++
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
runFailsafe(outState, pausingHandle) {
|
||||
val url = repo.getPageUrl(page)
|
||||
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
|
||||
output.addPage(
|
||||
chapter = chapter,
|
||||
file = file,
|
||||
pageNumber = pageIndex,
|
||||
ext = MimeTypeMap.getFileExtensionFromUrl(url),
|
||||
)
|
||||
}
|
||||
|
||||
outState.value = DownloadState.Progress(
|
||||
startId,
|
||||
data,
|
||||
cover,
|
||||
startId = startId,
|
||||
manga = data,
|
||||
cover = cover,
|
||||
totalChapters = chapters.size,
|
||||
currentChapter = chapterIndex,
|
||||
totalPages = pages.size,
|
||||
@@ -164,15 +150,40 @@ class DownloadManager @AssistedInject constructor(
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
outState.value = DownloadState.Error(startId, manga, cover, e)
|
||||
outState.value = DownloadState.Error(startId, manga, cover, e, false)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
output?.cleanup()
|
||||
File(destination, tempFileName).deleteAwait()
|
||||
coroutineContext[WakeLockNode]?.release()
|
||||
semaphore.release()
|
||||
localMangaRepository.unlockManga(manga.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <R> runFailsafe(
|
||||
outState: MutableStateFlow<DownloadState>,
|
||||
pausingHandle: PausingHandle,
|
||||
block: suspend () -> R,
|
||||
): R {
|
||||
var countDown = MAX_FAILSAFE_ATTEMPTS
|
||||
failsafe@ while (true) {
|
||||
try {
|
||||
return block()
|
||||
} catch (e: IOException) {
|
||||
if (countDown <= 0) {
|
||||
val state = outState.value
|
||||
outState.value = DownloadState.Error(state.startId, state.manga, state.cover, e, true)
|
||||
countDown = MAX_FAILSAFE_ATTEMPTS
|
||||
pausingHandle.pause()
|
||||
pausingHandle.awaitResumed()
|
||||
outState.value = state
|
||||
} else {
|
||||
countDown--
|
||||
delay(DOWNLOAD_ERROR_DELAY)
|
||||
}
|
||||
}
|
||||
coroutineContext[WakeLockNode]?.release()
|
||||
semaphore.release()
|
||||
localMangaRepository.unlockManga(manga.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +213,7 @@ class DownloadManager @AssistedInject constructor(
|
||||
manga = prevValue.manga,
|
||||
cover = prevValue.cover,
|
||||
error = throwable,
|
||||
canRetry = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ sealed interface DownloadState {
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("TODO: remove")
|
||||
class WaitingForNetwork(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
@@ -170,6 +171,7 @@ sealed interface DownloadState {
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
val error: Throwable,
|
||||
val canRetry: Boolean,
|
||||
) : DownloadState {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
@@ -182,6 +184,7 @@ sealed interface DownloadState {
|
||||
if (manga != other.manga) return false
|
||||
if (cover != other.cover) return false
|
||||
if (error != other.error) return false
|
||||
if (canRetry != other.canRetry) return false
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -191,6 +194,7 @@ sealed interface DownloadState {
|
||||
result = 31 * result + manga.hashCode()
|
||||
result = 31 * result + (cover?.hashCode() ?: 0)
|
||||
result = 31 * result + error.hashCode()
|
||||
result = 31 * result + canRetry.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,16 +29,26 @@ class DownloadNotification(private val context: Context, startId: Int) {
|
||||
context.getString(android.R.string.cancel),
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
startId,
|
||||
startId * 2,
|
||||
DownloadService.getCancelIntent(startId),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
|
||||
),
|
||||
)
|
||||
private val retryAction = NotificationCompat.Action(
|
||||
R.drawable.ic_restart_black,
|
||||
context.getString(R.string.try_again),
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
startId * 2 + 1,
|
||||
DownloadService.getResumeIntent(startId),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
private val listIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
REQUEST_LIST,
|
||||
DownloadsActivity.newIntent(context),
|
||||
PendingIntentCompat.FLAG_IMMUTABLE,
|
||||
PendingIntentCompat.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
init {
|
||||
@@ -89,10 +99,14 @@ class DownloadNotification(private val context: Context, startId: Int) {
|
||||
builder.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
builder.setSubText(context.getString(R.string.error))
|
||||
builder.setContentText(message)
|
||||
builder.setAutoCancel(true)
|
||||
builder.setOngoing(false)
|
||||
builder.setAutoCancel(!state.canRetry)
|
||||
builder.setOngoing(state.canRetry)
|
||||
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
if (state.canRetry) {
|
||||
builder.addAction(cancelAction)
|
||||
builder.addAction(retryAction)
|
||||
}
|
||||
}
|
||||
is DownloadState.PostProcessing -> {
|
||||
builder.setProgress(1, 0, true)
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.download.domain.DownloadState
|
||||
import org.koitharu.kotatsu.download.domain.WakeLockNode
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.utils.ext.throttle
|
||||
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
|
||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
|
||||
|
||||
@@ -39,7 +40,7 @@ class DownloadService : BaseService() {
|
||||
@Inject
|
||||
lateinit var downloadManagerFactory: DownloadManager.Factory
|
||||
|
||||
private val jobs = LinkedHashMap<Int, ProgressJob<DownloadState>>()
|
||||
private val jobs = LinkedHashMap<Int, PausingProgressJob<DownloadState>>()
|
||||
private val jobCount = MutableStateFlow(0)
|
||||
private val controlReceiver = ControlReceiver()
|
||||
private var binder: DownloadBinder? = null
|
||||
@@ -54,7 +55,10 @@ class DownloadService : BaseService() {
|
||||
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
|
||||
)
|
||||
DownloadNotification.createChannel(this)
|
||||
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
|
||||
val intentFilter = IntentFilter()
|
||||
intentFilter.addAction(ACTION_DOWNLOAD_CANCEL)
|
||||
intentFilter.addAction(ACTION_DOWNLOAD_RESUME)
|
||||
registerReceiver(controlReceiver, intentFilter)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -92,7 +96,7 @@ class DownloadService : BaseService() {
|
||||
startId: Int,
|
||||
manga: Manga,
|
||||
chaptersIds: LongArray?,
|
||||
): ProgressJob<DownloadState> {
|
||||
): PausingProgressJob<DownloadState> {
|
||||
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
|
||||
listenJob(job)
|
||||
return job
|
||||
@@ -146,7 +150,7 @@ class DownloadService : BaseService() {
|
||||
}
|
||||
|
||||
private val DownloadState.isTerminal: Boolean
|
||||
get() = this is DownloadState.Done || this is DownloadState.Error || this is DownloadState.Cancelled
|
||||
get() = this is DownloadState.Done || this is DownloadState.Cancelled || (this is DownloadState.Error && !canRetry)
|
||||
|
||||
inner class ControlReceiver : BroadcastReceiver() {
|
||||
|
||||
@@ -157,6 +161,10 @@ class DownloadService : BaseService() {
|
||||
jobs.remove(cancelId)?.cancel()
|
||||
jobCount.value = jobs.size
|
||||
}
|
||||
ACTION_DOWNLOAD_RESUME -> {
|
||||
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
|
||||
jobs[cancelId]?.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,6 +183,7 @@ class DownloadService : BaseService() {
|
||||
const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
|
||||
|
||||
private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
|
||||
private const val ACTION_DOWNLOAD_RESUME = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_RESUME"
|
||||
|
||||
private const val EXTRA_MANGA = "manga"
|
||||
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
|
||||
@@ -217,6 +226,9 @@ class DownloadService : BaseService() {
|
||||
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
|
||||
.putExtra(EXTRA_CANCEL_ID, startId)
|
||||
|
||||
fun getResumeIntent(startId: Int) = Intent(ACTION_DOWNLOAD_RESUME)
|
||||
.putExtra(EXTRA_CANCEL_ID, startId)
|
||||
|
||||
fun getDownloadedManga(intent: Intent?): Manga? {
|
||||
if (intent?.action == ACTION_DOWNLOAD_COMPLETE) {
|
||||
return intent.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.koitharu.kotatsu.download.ui.service
|
||||
|
||||
import androidx.annotation.AnyThread
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class PausingHandle {
|
||||
|
||||
private val paused = MutableStateFlow(false)
|
||||
|
||||
@get:AnyThread
|
||||
val isPaused: Boolean
|
||||
get() = paused.value
|
||||
|
||||
@AnyThread
|
||||
suspend fun awaitResumed() {
|
||||
paused.filter { !it }.first()
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun pause() {
|
||||
paused.value = true
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun resume() {
|
||||
paused.value = false
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.core.net.toUri
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
@@ -61,7 +62,11 @@ class PageSaveHelper @Inject constructor(
|
||||
} != null
|
||||
|
||||
private suspend fun getProposedFileName(url: String, file: File): String {
|
||||
var name = url.toHttpUrl().pathSegments.last()
|
||||
var name = if (url.startsWith("cbz://")) {
|
||||
requireNotNull(url.toUri().fragment)
|
||||
} else {
|
||||
url.toHttpUrl().pathSegments.last()
|
||||
}
|
||||
var extension = name.substringAfterLast('.', "")
|
||||
name = name.substringBeforeLast('.')
|
||||
if (extension.length !in 2..4) {
|
||||
|
||||
@@ -7,6 +7,8 @@ import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
@@ -26,6 +28,8 @@ open class PageHolder(
|
||||
View.OnClickListener {
|
||||
|
||||
init {
|
||||
binding.ssiv.setExecutor(Dispatchers.Default.asExecutor())
|
||||
binding.ssiv.setEagerLoadingEnabled(!isLowRamDevice(context))
|
||||
binding.ssiv.setOnImageEventListener(delegate)
|
||||
@Suppress("LeakingThis")
|
||||
bindingInfo.buttonRetry.setOnClickListener(this)
|
||||
|
||||
@@ -3,13 +3,16 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
class WebtoonFrameLayout @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val target by lazy {
|
||||
private val target by lazy(LazyThreadSafetyMode.NONE) {
|
||||
findViewById<WebtoonImageView>(R.id.ssiv)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import org.koitharu.kotatsu.utils.GoneOnInvisibleListener
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
|
||||
|
||||
class WebtoonHolder(
|
||||
binding: ItemPageWebtoonBinding,
|
||||
loader: PageLoader,
|
||||
settings: AppSettings,
|
||||
exceptionResolver: ExceptionResolver
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, exceptionResolver),
|
||||
View.OnClickListener {
|
||||
|
||||
@@ -29,6 +29,7 @@ class WebtoonHolder(
|
||||
init {
|
||||
binding.ssiv.setOnImageEventListener(delegate)
|
||||
bindingInfo.buttonRetry.setOnClickListener(this)
|
||||
GoneOnInvisibleListener(bindingInfo.progressBar).attach()
|
||||
}
|
||||
|
||||
override fun onBind(data: ReaderPage) {
|
||||
@@ -61,9 +62,9 @@ class WebtoonHolder(
|
||||
|
||||
override fun onImageShowing(zoom: ZoomMode) {
|
||||
with(binding.ssiv) {
|
||||
maxScale = 2f * width / sWidth.toFloat()
|
||||
setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM)
|
||||
minScale = width / sWidth.toFloat()
|
||||
maxScale = minScale
|
||||
scrollTo(
|
||||
when {
|
||||
scrollToRestore != 0 -> scrollToRestore
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.graphics.PointF
|
||||
import android.util.AttributeSet
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import org.koitharu.kotatsu.parsers.util.toIntUp
|
||||
import org.koitharu.kotatsu.utils.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.utils.ext.parents
|
||||
|
||||
private const val SCROLL_UNKNOWN = -1
|
||||
|
||||
@@ -15,15 +19,15 @@ class WebtoonImageView @JvmOverloads constructor(
|
||||
) : SubsamplingScaleImageView(context, attr) {
|
||||
|
||||
private val ct = PointF()
|
||||
private val displayHeight = if (context is Activity) {
|
||||
context.window.decorView.height
|
||||
} else {
|
||||
context.resources.displayMetrics.heightPixels
|
||||
}
|
||||
|
||||
private var scrollPos = 0
|
||||
private var scrollRange = SCROLL_UNKNOWN
|
||||
|
||||
init {
|
||||
setExecutor(Dispatchers.Default.asExecutor())
|
||||
setEagerLoadingEnabled(!isLowRamDevice(context))
|
||||
}
|
||||
|
||||
fun scrollBy(delta: Int) {
|
||||
val maxScroll = getScrollRange()
|
||||
if (maxScroll == 0) {
|
||||
@@ -36,6 +40,7 @@ class WebtoonImageView @JvmOverloads constructor(
|
||||
fun scrollTo(y: Int) {
|
||||
val maxScroll = getScrollRange()
|
||||
if (maxScroll == 0) {
|
||||
resetScaleAndCenter()
|
||||
return
|
||||
}
|
||||
scrollToInternal(y.coerceIn(0, maxScroll))
|
||||
@@ -58,8 +63,11 @@ class WebtoonImageView @JvmOverloads constructor(
|
||||
|
||||
override fun getSuggestedMinimumHeight(): Int {
|
||||
var desiredHeight = super.getSuggestedMinimumHeight()
|
||||
if (sHeight == 0 && desiredHeight < displayHeight) {
|
||||
desiredHeight = displayHeight
|
||||
if (sHeight == 0) {
|
||||
val parentHeight = parentHeight()
|
||||
if (desiredHeight < parentHeight) {
|
||||
desiredHeight = parentHeight
|
||||
}
|
||||
}
|
||||
return desiredHeight
|
||||
}
|
||||
@@ -84,7 +92,7 @@ class WebtoonImageView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
width = width.coerceAtLeast(suggestedMinimumWidth)
|
||||
height = height.coerceIn(suggestedMinimumHeight, displayHeight)
|
||||
height = height.coerceIn(suggestedMinimumHeight, parentHeight())
|
||||
setMeasuredDimension(width, height)
|
||||
}
|
||||
|
||||
@@ -101,4 +109,8 @@ class WebtoonImageView @JvmOverloads constructor(
|
||||
val totalHeight = (sHeight * minScale).toIntUp()
|
||||
scrollRange = (totalHeight - height).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
private fun parentHeight(): Int {
|
||||
return parents.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlin.math.sign
|
||||
|
||||
@Suppress("unused")
|
||||
class WebtoonLayoutManager : LinearLayoutManager {
|
||||
|
||||
private var scrollDirection: Int = 0
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
|
||||
import android.graphics.drawable.Drawable
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Scale
|
||||
import coil.size.Size
|
||||
import com.google.android.material.R as materialR
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
@@ -23,14 +24,13 @@ fun pageThumbnailAD(
|
||||
loader: PageLoader,
|
||||
clickListener: OnListItemClickListener<MangaPage>,
|
||||
) = adapterDelegateViewBinding<PageThumbnail, PageThumbnail, ItemPageThumbBinding>(
|
||||
{ inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) }
|
||||
{ inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
|
||||
var job: Job? = null
|
||||
val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width)
|
||||
val thumbSize = Size(
|
||||
width = gridWidth,
|
||||
height = (gridWidth * 13f / 18f).toInt()
|
||||
height = (gridWidth * 13f / 18f).toInt(),
|
||||
)
|
||||
|
||||
suspend fun loadPageThumbnail(item: PageThumbnail): Drawable? = withContext(Dispatchers.Default) {
|
||||
@@ -40,8 +40,9 @@ fun pageThumbnailAD(
|
||||
.data(url)
|
||||
.referer(item.page.referer)
|
||||
.size(thumbSize)
|
||||
.allowRgb565(isLowRamDevice(context))
|
||||
.build()
|
||||
.scale(Scale.FILL)
|
||||
.allowRgb565(true)
|
||||
.build(),
|
||||
).drawable
|
||||
}?.let { drawable ->
|
||||
return@withContext drawable
|
||||
@@ -52,7 +53,7 @@ fun pageThumbnailAD(
|
||||
.data(file)
|
||||
.size(thumbSize)
|
||||
.allowRgb565(isLowRamDevice(context))
|
||||
.build()
|
||||
.build(),
|
||||
).drawable
|
||||
}
|
||||
|
||||
@@ -81,4 +82,4 @@ fun pageThumbnailAD(
|
||||
job = null
|
||||
binding.imageViewThumb.setImageDrawable(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.koitharu.kotatsu.utils
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
|
||||
/**
|
||||
* ProgressIndicator become INVISIBLE instead of GONE by hide() call.
|
||||
* It`s final so we need this workaround
|
||||
*/
|
||||
class GoneOnInvisibleListener(
|
||||
private val view: View,
|
||||
) : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
|
||||
override fun onGlobalLayout() {
|
||||
if (view.visibility == View.INVISIBLE) {
|
||||
view.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
fun attach() {
|
||||
view.viewTreeObserver.addOnGlobalLayoutListener(this)
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import android.content.pm.ResolveInfo
|
||||
import android.database.SQLException
|
||||
import android.graphics.Color
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkRequest
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
@@ -29,7 +27,6 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import androidx.work.CoroutineWorker
|
||||
import com.google.android.material.elevation.ElevationOverlayProvider
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.roundToLong
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
@@ -39,7 +36,6 @@ import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okio.IOException
|
||||
import org.json.JSONException
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
@@ -51,28 +47,6 @@ val Context.activityManager: ActivityManager?
|
||||
val Context.connectivityManager: ConnectivityManager
|
||||
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
suspend fun ConnectivityManager.waitForNetwork(): Network {
|
||||
val request = NetworkRequest.Builder().build()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// fast path
|
||||
activeNetwork?.let { return it }
|
||||
}
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
unregisterNetworkCallback(this)
|
||||
if (cont.isActive) {
|
||||
cont.resume(network)
|
||||
}
|
||||
}
|
||||
}
|
||||
registerNetworkCallback(request, callback)
|
||||
cont.invokeOnCancellation {
|
||||
unregisterNetworkCallback(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
||||
|
||||
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching {
|
||||
|
||||
@@ -160,8 +160,6 @@ fun RecyclerView.invalidateNestedItemDecorations() {
|
||||
}
|
||||
}
|
||||
|
||||
internal val View.compatPaddingStart get() = ViewCompat.getPaddingStart(this)
|
||||
|
||||
val View.parents: Sequence<ViewParent>
|
||||
get() = sequence {
|
||||
var p: ViewParent? = parent
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.koitharu.kotatsu.utils.progress
|
||||
|
||||
import androidx.annotation.AnyThread
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koitharu.kotatsu.download.ui.service.PausingHandle
|
||||
|
||||
class PausingProgressJob<P>(
|
||||
job: Job,
|
||||
progress: StateFlow<P>,
|
||||
private val pausingHandle: PausingHandle,
|
||||
) : ProgressJob<P>(job, progress) {
|
||||
|
||||
@get:AnyThread
|
||||
val isPaused: Boolean
|
||||
get() = pausingHandle.isPaused
|
||||
|
||||
@AnyThread
|
||||
suspend fun awaitResumed() = pausingHandle.awaitResumed()
|
||||
|
||||
@AnyThread
|
||||
fun pause() = pausingHandle.pause()
|
||||
|
||||
@AnyThread
|
||||
fun resume() = pausingHandle.resume()
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class ProgressJob<P>(
|
||||
open class ProgressJob<P>(
|
||||
private val job: Job,
|
||||
private val progress: StateFlow<P>,
|
||||
) : Job by job {
|
||||
|
||||
8
app/src/main/res/drawable/ic_restart_black.xml
Normal file
8
app/src/main/res/drawable/ic_restart_black.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:height="24dp"
|
||||
android:width="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:fillColor="#000" android:pathData="M12,4C14.1,4 16.1,4.8 17.6,6.3C20.7,9.4 20.7,14.5 17.6,17.6C15.8,19.5 13.3,20.2 10.9,19.9L11.4,17.9C13.1,18.1 14.9,17.5 16.2,16.2C18.5,13.9 18.5,10.1 16.2,7.7C15.1,6.6 13.5,6 12,6V10.6L7,5.6L12,0.6V4M6.3,17.6C3.7,15 3.3,11 5.1,7.9L6.6,9.4C5.5,11.6 5.9,14.4 7.8,16.2C8.3,16.7 8.9,17.1 9.6,17.4L9,19.4C8,19 7.1,18.4 6.3,17.6Z" />
|
||||
</vector>
|
||||
Reference in New Issue
Block a user