Merge branch 'devel' into feature/shikimori

This commit is contained in:
Koitharu
2022-05-09 09:42:03 +03:00
248 changed files with 5174 additions and 1702 deletions

View File

@@ -10,14 +10,13 @@ open class BottomSheetToolbarController(
) : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
val isExpanded = newState == BottomSheetBehavior.STATE_EXPANDED && bottomSheet.top <= 0
if (isExpanded) {
toolbar.setNavigationIcon(materialR.drawable.abc_ic_clear_material)
} else {
toolbar.navigationIcon = null
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
}
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
}

View File

@@ -0,0 +1,66 @@
package org.koitharu.kotatsu.utils
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.*
import kotlin.coroutines.resume
class CompositeMutex<T : Any> : Set<T> {
private val data = HashMap<T, MutableList<CancellableContinuation<Unit>>>()
private val mutex = Mutex()
override val size: Int
get() = data.size
override fun contains(element: T): Boolean {
return data.containsKey(element)
}
override fun containsAll(elements: Collection<T>): Boolean {
return elements.all { x -> data.containsKey(x) }
}
override fun isEmpty(): Boolean {
return data.isEmpty()
}
override fun iterator(): Iterator<T> {
return data.keys.iterator()
}
suspend fun lock(element: T) {
waitForRemoval(element)
mutex.withLock {
val lastValue = data.put(element, LinkedList<CancellableContinuation<Unit>>())
check(lastValue == null) {
"CompositeMutex is double-locked for $element"
}
}
}
suspend fun unlock(element: T) {
val continuations = mutex.withLock {
checkNotNull(data.remove(element)) {
"CompositeMutex is not locked for $element"
}
}
continuations.forEach { c ->
if (c.isActive) {
c.resume(Unit)
}
}
}
private suspend fun waitForRemoval(element: T) {
val list = data[element] ?: return
suspendCancellableCoroutine<Unit> { continuation ->
list.add(continuation)
continuation.invokeOnCancellation {
list.remove(continuation)
}
}
}
}

View File

@@ -1,26 +0,0 @@
package org.koitharu.kotatsu.utils
import android.content.Context
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.IOException
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
class ExternalStorageHelper(context: Context) {
private val contentResolver = context.contentResolver
suspend fun savePage(page: MangaPage, destination: Uri) {
val pageLoader = PageLoader()
val pageFile = pageLoader.loadPage(page, force = false)
runInterruptible(Dispatchers.IO) {
contentResolver.openOutputStream(destination)?.use { output ->
pageFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: throw IOException("Output stream is null")
}
}
}

View File

@@ -32,4 +32,4 @@ enum class FileSize(private val multiplier: Int) {
}
}
}
}
}

View File

@@ -5,8 +5,10 @@ import android.view.GestureDetector
import android.view.MotionEvent
import kotlin.math.roundToInt
class GridTouchHelper(context: Context, private val listener: OnGridTouchListener) :
GestureDetector.SimpleOnGestureListener() {
class GridTouchHelper(
context: Context,
private val listener: OnGridTouchListener
) : GestureDetector.SimpleOnGestureListener() {
private val detector = GestureDetector(context, this)
private val width = context.resources.displayMetrics.widthPixels

View File

@@ -1,103 +0,0 @@
package org.koitharu.kotatsu.utils
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
open class MutableZipFile(val file: File) {
protected val dir = File(file.parentFile, file.nameWithoutExtension)
suspend fun unpack(): Unit = runInterruptible(Dispatchers.IO) {
check(dir.list().isNullOrEmpty()) {
"Dir ${dir.name} is not empty"
}
if (!dir.exists()) {
dir.mkdir()
}
if (!file.exists()) {
return@runInterruptible
}
ZipInputStream(FileInputStream(file)).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
val target = File(dir.path + File.separator + entry.name)
target.parentFile?.mkdirs()
target.outputStream().use { out ->
zip.copyTo(out)
}
zip.closeEntry()
entry = zip.nextEntry
}
}
}
suspend fun cleanup() = withContext(Dispatchers.IO) {
dir.deleteRecursively()
}
suspend fun flush(): Boolean = runInterruptible(Dispatchers.IO) {
val tempFile = File(file.path + ".tmp")
if (tempFile.exists()) {
tempFile.delete()
}
try {
ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
dir.listFiles()?.forEach {
zipFile(it, it.name, zip)
}
zip.flush()
}
tempFile.renameTo(file)
} finally {
if (tempFile.exists()) {
tempFile.delete()
}
}
}
operator fun get(name: String) = File(dir, name)
suspend fun put(name: String, file: File): Unit = withContext(Dispatchers.IO) {
file.copyTo(this@MutableZipFile[name], overwrite = true)
}
suspend fun put(name: String, data: String): Unit = withContext(Dispatchers.IO) {
this@MutableZipFile[name].writeText(data)
}
suspend fun getContent(name: String): String = withContext(Dispatchers.IO) {
get(name).readText()
}
companion object {
@WorkerThread
private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) {
if (fileToZip.isDirectory) {
if (fileName.endsWith("/")) {
zipOut.putNextEntry(ZipEntry(fileName))
} else {
zipOut.putNextEntry(ZipEntry("$fileName/"))
}
zipOut.closeEntry()
fileToZip.listFiles()?.forEach { childFile ->
zipFile(childFile, "$fileName/${childFile.name}", zipOut)
}
} else {
FileInputStream(fileToZip).use { fis ->
val zipEntry = ZipEntry(fileName)
zipOut.putNextEntry(zipEntry)
fis.copyTo(zipOut)
}
}
}
}
}

View File

@@ -0,0 +1,50 @@
package org.koitharu.kotatsu.utils
import androidx.annotation.MainThread
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Runnable
class PausingDispatcher(
private val dispatcher: CoroutineDispatcher,
) : CoroutineDispatcher() {
@Volatile
private var isPaused = false
private val queue = ConcurrentLinkedQueue<Task>()
override fun isDispatchNeeded(context: CoroutineContext): Boolean {
return isPaused || super.isDispatchNeeded(context)
}
override fun dispatch(context: CoroutineContext, block: Runnable) {
if (isPaused) {
queue.add(Task(context, block))
} else {
dispatcher.dispatch(context, block)
}
}
@MainThread
fun pause() {
isPaused = true
}
@MainThread
fun resume() {
if (!isPaused) {
return
}
isPaused = false
while (true) {
val task = queue.poll() ?: break
dispatcher.dispatch(task.context, task.block)
}
}
private class Task(
val context: CoroutineContext,
val block: Runnable,
)
}

View File

@@ -1,10 +0,0 @@
package org.koitharu.kotatsu.utils
import kotlinx.coroutines.flow.MutableStateFlow
class SelectionController {
private val state = MutableStateFlow(emptySet<Int>())
}

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.utils
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.speech.RecognizerIntent
import androidx.activity.result.contract.ActivityResultContract
class VoiceInputContract : ActivityResultContract<String?, String?>() {
override fun createIntent(context: Context, input: String?): Intent {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
intent.putExtra(RecognizerIntent.EXTRA_PROMPT, input)
return intent
}
override fun parseResult(resultCode: Int, intent: Intent?): String? {
return if (resultCode == Activity.RESULT_OK && intent != null) {
val matches = intent.getStringArrayExtra(RecognizerIntent.EXTRA_RESULTS)
matches?.firstOrNull()
} else {
null
}
}
}

View File

@@ -1,9 +0,0 @@
package org.koitharu.kotatsu.utils
class WordSet(private vararg val words: String) {
fun anyWordIn(dateString: String): Boolean = words.any {
dateString.contains(it, ignoreCase = true)
}
}

View File

@@ -1,22 +1,31 @@
package org.koitharu.kotatsu.utils.ext
import android.content.Context
import android.content.pm.ResolveInfo
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.net.Uri
import android.os.Build
import androidx.activity.result.ActivityResultLauncher
import androidx.core.app.ActivityOptionsCompat
import androidx.work.CoroutineWorker
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
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)
}
@@ -34,4 +43,16 @@ fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching {
val info = getForegroundInfo()
setForeground(info)
}.isSuccess
}.isSuccess
fun <I> ActivityResultLauncher<I>.resolve(context: Context, input: I): ResolveInfo? {
val pm = context.packageManager
val intent = contract.createIntent(context, input)
return pm.resolveActivity(intent, 0)
}
fun <I> ActivityResultLauncher<I>.tryLaunch(input: I, options: ActivityOptionsCompat? = null): Boolean {
return runCatching {
launch(input, options)
}.isSuccess
}

View File

@@ -3,8 +3,6 @@ package org.koitharu.kotatsu.utils.ext
import androidx.collection.ArraySet
import java.util.*
fun LongArray.toArraySet(): Set<Long> = createSet(size) { i -> this[i] }
fun <T : Enum<T>> Array<T>.names() = Array(size) { i ->
this[i].name
}

View File

@@ -1,16 +1,14 @@
package org.koitharu.kotatsu.utils.ext
import android.content.res.Resources
import android.util.Log
import java.io.FileNotFoundException
import java.net.SocketTimeoutException
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.util.format
import java.io.FileNotFoundException
import java.net.SocketTimeoutException
fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required)
@@ -22,12 +20,4 @@ fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
is SocketTimeoutException -> resources.getString(R.string.network_error)
is WrongPasswordException -> resources.getString(R.string.wrong_password)
else -> localizedMessage ?: resources.getString(R.string.error_occurred)
}
inline fun <T> measured(tag: String, block: () -> T): T {
val time = System.currentTimeMillis()
val res = block()
val spent = System.currentTimeMillis() - time
Log.d("measured", "$tag ${spent.format(1)} ms")
return res
}

View File

@@ -3,10 +3,8 @@ package org.koitharu.kotatsu.utils.ext
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineExceptionHandler
import org.koitharu.kotatsu.BuildConfig
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
val IgnoreErrors
get() = CoroutineExceptionHandler { _, e ->

View File

@@ -15,6 +15,6 @@ fun Date.formatRelative(minResolution: Long): CharSequence = DateUtils.getRelati
fun Date.daysDiff(other: Long): Int {
val thisDay = time / TimeUnit.DAYS.toMillis(1L)
val otherDay = other/ TimeUnit.DAYS.toMillis(1L)
val otherDay = other / TimeUnit.DAYS.toMillis(1L)
return (thisDay - otherDay).toInt()
}

View File

@@ -26,11 +26,12 @@ fun <T : Parcelable> Fragment.parcelableArgument(name: String): Lazy<T> {
}
}
inline fun <reified T : Serializable> Fragment.serializableArgument(name: String): Lazy<T> {
fun <T : Serializable> Fragment.serializableArgument(name: String): Lazy<T> {
return lazy(LazyThreadSafetyMode.NONE) {
requireNotNull(arguments?.getSerializable(name) as? T) {
@Suppress("UNCHECKED_CAST")
requireNotNull(arguments?.getSerializable(name)) {
"No argument $name passed into ${javaClass.simpleName}"
}
} as T
}
}

View File

@@ -4,10 +4,10 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.liveData
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.utils.BufferedObserver
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.utils.BufferedObserver
fun <T> LiveData<T?>.observeNotNull(owner: LifecycleOwner, observer: Observer<T>) {
this.observe(owner) {

View File

@@ -9,17 +9,17 @@ fun ListPreference.setDefaultValueCompat(defaultValue: String) {
}
}
fun <E: Enum<E>> SharedPreferences.getEnumValue(key: String, enumClass: Class<E>): E? {
fun <E : Enum<E>> SharedPreferences.getEnumValue(key: String, enumClass: Class<E>): E? {
val stringValue = getString(key, null) ?: return null
return enumClass.enumConstants?.find {
it.name == stringValue
}
}
fun <E: Enum<E>> SharedPreferences.getEnumValue(key: String, defaultValue: E): E {
fun <E : Enum<E>> SharedPreferences.getEnumValue(key: String, defaultValue: E): E {
return getEnumValue(key, defaultValue.javaClass) ?: defaultValue
}
fun <E: Enum<E>> SharedPreferences.Editor.putEnumValue(key: String, value: E?) {
fun <E : Enum<E>> SharedPreferences.Editor.putEnumValue(key: String, value: E?) {
putString(key, value?.name)
}

View File

@@ -5,12 +5,11 @@ import android.view.View
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.annotation.StringRes
import androidx.core.content.res.use
import androidx.core.view.isGone
var TextView.textAndVisible: CharSequence?
inline get() = text?.takeIf { visibility == View.VISIBLE }
inline set(value) {
get() = text?.takeIf { visibility == View.VISIBLE }
set(value) {
text = value
isGone = value.isNullOrEmpty()
}
@@ -40,8 +39,5 @@ fun TextView.setTextAndVisible(@StringRes textResId: Int) {
}
fun TextView.setTextColorAttr(@AttrRes attrResId: Int) {
val colors = context.obtainStyledAttributes(intArrayOf(attrResId)).use {
it.getColorStateList(0)
}
setTextColor(colors)
setTextColor(context.getThemeColorStateList(attrResId))
}

View File

@@ -4,20 +4,24 @@ import android.content.Context
import android.graphics.Color
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.Px
import androidx.core.content.res.use
@Px
fun Context.getThemeDimen(@AttrRes resId: Int) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getDimension(0, 0f)
}
fun Context.getThemeDrawable(@AttrRes resId: Int) = obtainStyledAttributes(intArrayOf(resId)).use {
fun Context.getThemeDrawable(
@AttrRes resId: Int,
) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getDrawable(0)
}
@ColorInt
fun Context.getThemeColor(@AttrRes resId: Int, @ColorInt default: Int = Color.TRANSPARENT) =
obtainStyledAttributes(intArrayOf(resId)).use {
it.getColor(0, default)
}
fun Context.getThemeColor(
@AttrRes resId: Int,
@ColorInt default: Int = Color.TRANSPARENT
) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getColor(0, default)
}
fun Context.getThemeColorStateList(
@AttrRes resId: Int,
) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getColorStateList(0)
}

View File

@@ -2,20 +2,15 @@ package org.koitharu.kotatsu.utils.ext
import android.app.Activity
import android.graphics.Rect
import android.view.LayoutInflater
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.annotation.LayoutRes
import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.children
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.slider.Slider
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewHolder
import kotlin.math.roundToInt
fun View.hideKeyboard() {
@@ -28,19 +23,15 @@ fun View.showKeyboard() {
imm.showSoftInput(this, 0)
}
inline fun <reified T : View> ViewGroup.inflate(@LayoutRes resId: Int) =
LayoutInflater.from(context).inflate(resId, this, false) as T
val RecyclerView.hasItems: Boolean
get() = (adapter?.itemCount ?: 0) > 0
fun RecyclerView.clearItemDecorations() {
suppressLayout(true)
while (itemDecorationCount > 0) {
removeItemDecorationAt(0)
}
suppressLayout(false)
}
var RecyclerView.firstItem: Int
var RecyclerView.firstVisibleItemPosition: Int
get() = (layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()
?: RecyclerView.NO_POSITION
set(value) {
@@ -49,18 +40,6 @@ var RecyclerView.firstItem: Int
}
}
inline fun View.showPopupMenu(
@MenuRes menuRes: Int,
onPrepare: (Menu) -> Unit = {},
onItemClick: PopupMenu.OnMenuItemClickListener,
) {
val menu = PopupMenu(context, this)
menu.inflate(menuRes)
menu.setOnMenuItemClickListener(onItemClick)
onPrepare(menu.menu)
menu.show()
}
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
if (visibility != View.VISIBLE) {
return false
@@ -97,7 +76,7 @@ inline fun ViewPager2.doOnPageChanged(crossinline callback: (Int) -> Unit) {
}
val ViewPager2.recyclerView: RecyclerView?
inline get() = children.find { it is RecyclerView } as? RecyclerView
get() = children.firstNotNullOfOrNull { it as? RecyclerView }
fun View.resetTransformations() {
alpha = 1f
@@ -106,6 +85,7 @@ fun View.resetTransformations() {
translationZ = 0f
scaleX = 1f
scaleY = 1f
rotation = 0f
rotationX = 0f
rotationY = 0f
}
@@ -133,8 +113,17 @@ fun RecyclerView.findCenterViewPosition(): Int {
return getChildAdapterPosition(view)
}
inline fun <reified T> RecyclerView.ViewHolder.getItem(): T? {
return ((this as? AdapterDelegateViewBindingViewHolder<*, *>)?.item as? T)
fun <T> RecyclerView.ViewHolder.getItem(clazz: Class<T>): T? {
val rawItem = when (this) {
is AdapterDelegateViewBindingViewHolder<*, *> -> item
is AdapterDelegateViewHolder<*> -> item
else -> null
} ?: return null
return if (clazz.isAssignableFrom(rawItem.javaClass)) {
clazz.cast(rawItem)
} else {
null
}
}
fun Slider.setValueRounded(newValue: Float) {

View File

@@ -1,7 +1,8 @@
package org.koitharu.kotatsu.utils.progress
import coil.request.ErrorResult
import coil.request.ImageRequest
import coil.request.ImageResult
import coil.request.SuccessResult
import com.google.android.material.progressindicator.BaseProgressIndicator
class ImageRequestIndicatorListener(
@@ -10,9 +11,9 @@ class ImageRequestIndicatorListener(
override fun onCancel(request: ImageRequest) = indicator.hide()
override fun onError(request: ImageRequest, throwable: Throwable) = indicator.hide()
override fun onError(request: ImageRequest, result: ErrorResult) = indicator.hide()
override fun onStart(request: ImageRequest) = indicator.show()
override fun onSuccess(request: ImageRequest, metadata: ImageResult.Metadata) = indicator.hide()
override fun onSuccess(request: ImageRequest, result: SuccessResult) = indicator.hide()
}

View File

@@ -1,7 +1,12 @@
package org.koitharu.kotatsu.utils.progress
import android.content.Context
import com.google.android.material.slider.LabelFormatter
import org.koitharu.kotatsu.R
class IntPercentLabelFormatter : LabelFormatter {
override fun getFormattedValue(value: Float) = "%d%%".format(value.toInt())
class IntPercentLabelFormatter(context: Context) : LabelFormatter {
private val pattern = context.getString(R.string.percent_string_pattern)
override fun getFormattedValue(value: Float) = pattern.format(value.toInt().toString())
}

View File

@@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class Progress(
val value: Int,
val total: Int
val total: Int,
) : Parcelable, Comparable<Progress> {
override fun compareTo(other: Progress): Int {

View File

@@ -0,0 +1,50 @@
package org.koitharu.kotatsu.utils.progress
import android.os.SystemClock
import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt
import kotlin.math.roundToLong
private const val MIN_ESTIMATE_TICKS = 4
private const val NO_TIME = -1L
class TimeLeftEstimator {
private var times = ArrayList<Int>()
private var lastTick: Tick? = null
private val tooLargeTime = TimeUnit.DAYS.toMillis(1)
fun tick(value: Int, total: Int) {
if (total < 0) {
emptyTick()
return
}
val tick = Tick(value, total, SystemClock.elapsedRealtime())
lastTick?.let {
val ticksCount = value - it.value
times.add(((tick.time - it.time) / ticksCount.toDouble()).roundToInt())
}
lastTick = tick
}
fun emptyTick() {
lastTick = null
}
fun getEstimatedTimeLeft(): Long {
val progress = lastTick ?: return NO_TIME
if (times.size < MIN_ESTIMATE_TICKS) {
return NO_TIME
}
val timePerTick = times.average()
val ticksLeft = progress.total - progress.value
val eta = (ticksLeft * timePerTick).roundToLong()
return if (eta < tooLargeTime) eta else NO_TIME
}
private class Tick(
val value: Int,
val total: Int,
val time: Long,
)
}