Refactor scroll timer

This commit is contained in:
Koitharu
2023-03-24 15:03:40 +02:00
parent bc4dd1c507
commit 43d55cedae
8 changed files with 163 additions and 159 deletions

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.annotation.FloatRange
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arraySetOf import androidx.collection.arraySetOf
import androidx.core.content.edit import androidx.core.content.edit
@@ -265,6 +266,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isWebtoonZoomEnable: Boolean val isWebtoonZoomEnable: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true) get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
@get:FloatRange(from = 0.0, to = 1.0)
var readerAutoscrollSpeed: Float
get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f)
set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { putFloat(KEY_READER_AUTOSCROLL_SPEED, value) }
fun isPagesPreloadEnabled(): Boolean { fun isPagesPreloadEnabled(): Boolean {
val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED) val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED)
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
@@ -397,6 +403,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_UPDATES_UNSTABLE = "updates_unstable" const val KEY_UPDATES_UNSTABLE = "updates_unstable"
const val KEY_TIPS_CLOSED = "tips_closed" const val KEY_TIPS_CLOSED = "tips_closed"
const val KEY_SSL_BYPASS = "ssl_bypass" const val KEY_SSL_BYPASS = "ssl_bypass"
const val KEY_READER_AUTOSCROLL_SPEED = "as_speed"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

@@ -53,6 +53,7 @@ import org.koitharu.kotatsu.utils.ext.observeWithPrevious
import org.koitharu.kotatsu.utils.ext.postDelayed import org.koitharu.kotatsu.utils.ext.postDelayed
import org.koitharu.kotatsu.utils.ext.setValueRounded import org.koitharu.kotatsu.utils.ext.setValueRounded
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ReaderActivity : class ReaderActivity :
@@ -69,15 +70,18 @@ class ReaderActivity :
private val viewModel: ReaderViewModel by viewModels() private val viewModel: ReaderViewModel by viewModels()
override var autoScrollSpeed: Float
get() = scrollTimer.speed
set(value) {
scrollTimer.speed = value
}
override val readerMode: ReaderMode? override val readerMode: ReaderMode?
get() = readerManager.currentMode get() = readerManager.currentMode
override var isAutoScrollEnabled: Boolean
get() = scrollTimer.isEnabled
set(value) {
scrollTimer.isEnabled = value
}
@Inject
lateinit var scrollTimerFactory: ScrollTimer.Factory
private lateinit var scrollTimer: ScrollTimer private lateinit var scrollTimer: ScrollTimer
private lateinit var touchHelper: GridTouchHelper private lateinit var touchHelper: GridTouchHelper
private lateinit var controlDelegate: ReaderControlDelegate private lateinit var controlDelegate: ReaderControlDelegate
@@ -91,7 +95,7 @@ class ReaderActivity :
readerManager = ReaderManager(supportFragmentManager, R.id.container) readerManager = ReaderManager(supportFragmentManager, R.id.container)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
touchHelper = GridTouchHelper(this, this) touchHelper = GridTouchHelper(this, this)
scrollTimer = ScrollTimer(this, this) scrollTimer = scrollTimerFactory.create(this, this)
controlDelegate = ReaderControlDelegate(settings, this, this) controlDelegate = ReaderControlDelegate(settings, this, this)
binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected) binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected)
binding.slider.setLabelFormatter(PageLabelFormatter()) binding.slider.setLabelFormatter(PageLabelFormatter())
@@ -133,6 +137,7 @@ class ReaderActivity :
override fun onUserInteraction() { override fun onUserInteraction() {
super.onUserInteraction() super.onUserInteraction()
scrollTimer.onUserInteraction()
idlingDetector.onUserInteraction() idlingDetector.onUserInteraction()
} }
@@ -343,6 +348,11 @@ class ReaderActivity :
setUiIsVisible(!binding.appbarTop.isVisible) setUiIsVisible(!binding.appbarTop.isVisible)
} }
override fun isReaderResumed(): Boolean {
val reader = readerManager.currentReader ?: return false
return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader
}
private fun onReaderBarChanged(isBarEnabled: Boolean) { private fun onReaderBarChanged(isBarEnabled: Boolean) {
binding.infoBar.isVisible = isBarEnabled && binding.appbarTop.isGone binding.infoBar.isVisible = isBarEnabled && binding.appbarTop.isGone
} }

View File

@@ -142,5 +142,7 @@ class ReaderControlDelegate(
fun scrollBy(delta: Int): Boolean fun scrollBy(delta: Int): Boolean
fun toggleUiVisibility() fun toggleUiVisibility()
fun isReaderResumed(): Boolean
} }
} }

View File

@@ -1,44 +1,65 @@
package org.koitharu.kotatsu.reader.ui package org.koitharu.kotatsu.reader.ui
import androidx.annotation.FloatRange
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.coroutineScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import kotlin.math.roundToLong import kotlin.math.roundToLong
private const val MIN_SPEED = 0.1 private const val MAX_DELAY = 60L
private const val MAX_DELAY = 80L private const val MAX_SWITCH_DELAY = 12_000L
private const val MAX_SWITCH_DELAY = 20_000L private const val INTERACTION_SKIP_MS = 1_000L
class ScrollTimer( class ScrollTimer @AssistedInject constructor(
private val lifecycleOwner: LifecycleOwner, @Assisted private val listener: ReaderControlDelegate.OnInteractionListener,
private val listener: ReaderControlDelegate.OnInteractionListener, @Assisted lifecycleOwner: LifecycleOwner,
settings: AppSettings,
) { ) {
private val coroutineScope = lifecycleOwner.lifecycleScope
private var job: Job? = null private var job: Job? = null
private var delayMs: Long = 10L private var delayMs: Long = 10L
private var pageSwitchDelay: Long = 100L private var pageSwitchDelay: Long = 100L
private var skip = 0L
@FloatRange(from = 0.0, to = 1.0) var isEnabled: Boolean = false
var speed: Float = 0f
set(value) { set(value) {
if (field != value) { if (field != value) {
field = value field = value
onSpeedChanged() restartJob()
} }
} }
private fun onSpeedChanged() { init {
if (speed < MIN_SPEED) { settings.observeAsFlow(AppSettings.KEY_READER_AUTOSCROLL_SPEED) {
readerAutoscrollSpeed
}.flowOn(Dispatchers.Default)
.onEach {
onSpeedChanged(it)
}.launchIn(coroutineScope)
}
fun onUserInteraction() {
skip = INTERACTION_SKIP_MS
}
private fun onSpeedChanged(speed: Float) {
if (speed <= 0f) {
delayMs = 0L delayMs = 0L
pageSwitchDelay = 0L pageSwitchDelay = 0L
} else { } else {
val speedFactor = 1 - speed + MIN_SPEED val speedFactor = 1 - speed
delayMs = (MAX_DELAY * speedFactor).roundToLong() delayMs = (MAX_DELAY * speedFactor).roundToLong()
pageSwitchDelay = (MAX_SWITCH_DELAY * speedFactor).roundToLong() pageSwitchDelay = (MAX_SWITCH_DELAY * speedFactor).roundToLong()
} }
@@ -49,24 +70,39 @@ class ScrollTimer(
private fun restartJob() { private fun restartJob() {
job?.cancel() job?.cancel()
if (delayMs == 0L) { skip = 0
if (!isEnabled || delayMs == 0L) {
job = null job = null
return return
} }
job = lifecycleOwner.lifecycle.coroutineScope.launch { job = coroutineScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { var accumulator = 0L
var accumulator = 0L while (isActive) {
while (isActive) { delay(delayMs)
delay(delayMs) if (!listener.isReaderResumed()) {
if (!listener.scrollBy(1)) { continue
accumulator += delayMs }
} skip -= delayMs
if (accumulator >= pageSwitchDelay) { if (skip > 0) {
listener.switchPageBy(1) continue
accumulator -= pageSwitchDelay }
} if (!listener.scrollBy(1)) {
accumulator += delayMs
}
if (accumulator >= pageSwitchDelay) {
listener.switchPageBy(1)
accumulator -= pageSwitchDelay
} }
} }
} }
} }
@AssistedFactory
interface Factory {
fun create(
lifecycleOwner: LifecycleOwner,
listener: ReaderControlDelegate.OnInteractionListener,
): ScrollTimer
}
} }

View File

@@ -1,75 +0,0 @@
package org.koitharu.kotatsu.reader.ui.config
import android.content.res.Resources
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.slider.LabelFormatter
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.reader.ui.ReaderControlDelegate
import kotlin.math.roundToLong
@Deprecated("")
class PageSwitchTimer(
private val listener: ReaderControlDelegate.OnInteractionListener,
private val lifecycleOwner: LifecycleOwner,
) {
var delaySec: Float = 0f
set(value) {
field = value
delayMs = mapDelay(value)
restartJob()
}
private var delayMs = 0L
fun onUserInteraction() {
restartJob()
}
private var job: Job? = null
private fun restartJob() {
job?.cancel()
if (delayMs == 0L) {
job = null
return
}
job = lifecycleOwner.lifecycle.coroutineScope.launch {
// FIXME: pause when bs is opened
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
while (isActive) {
delay(delayMs)
listener.switchPageBy(1)
}
}
}
}
class DelayLabelFormatter(resources: Resources) : LabelFormatter {
private val textOff = resources.getString(R.string.off_short)
private val textSec = resources.getString(R.string.seconds_pattern)
override fun getFormattedValue(value: Float): String {
val ms = mapDelay(value)
return if (ms == 0L) textOff else textSec.format((ms / 1000.0).format(1))
}
}
companion object {
private const val DELAY_MIN = 2000L
fun mapDelay(value: Float): Long {
val delay = (value * 1000L).roundToLong()
return if (delay < DELAY_MIN) 0L else delay
}
}
}

View File

@@ -5,19 +5,25 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.CompoundButton
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
import androidx.annotation.FloatRange
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding
import org.koitharu.kotatsu.reader.ui.PageSaveContract import org.koitharu.kotatsu.reader.ui.PageSaveContract
import org.koitharu.kotatsu.reader.ui.ReaderViewModel import org.koitharu.kotatsu.reader.ui.ReaderViewModel
@@ -26,19 +32,24 @@ import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.ScreenOrientationHelper import org.koitharu.kotatsu.utils.ScreenOrientationHelper
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint
class ReaderConfigBottomSheet : class ReaderConfigBottomSheet :
BaseBottomSheet<SheetReaderConfigBinding>(), BaseBottomSheet<SheetReaderConfigBinding>(),
ActivityResultCallback<Uri?>, ActivityResultCallback<Uri?>,
View.OnClickListener, View.OnClickListener,
MaterialButtonToggleGroup.OnButtonCheckedListener, MaterialButtonToggleGroup.OnButtonCheckedListener,
Slider.OnChangeListener { Slider.OnChangeListener, CompoundButton.OnCheckedChangeListener {
private val viewModel by activityViewModels<ReaderViewModel>() private val viewModel by activityViewModels<ReaderViewModel>()
private val savePageRequest = registerForActivityResult(PageSaveContract(), this) private val savePageRequest = registerForActivityResult(PageSaveContract(), this)
private var orientationHelper: ScreenOrientationHelper? = null private var orientationHelper: ScreenOrientationHelper? = null
private lateinit var mode: ReaderMode private lateinit var mode: ReaderMode
@Inject
lateinit var settings: AppSettings
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
mode = arguments?.getInt(ARG_MODE) mode = arguments?.getInt(ARG_MODE)
@@ -63,9 +74,17 @@ class ReaderConfigBottomSheet :
binding.buttonSettings.setOnClickListener(this) binding.buttonSettings.setOnClickListener(this)
binding.buttonColorFilter.setOnClickListener(this) binding.buttonColorFilter.setOnClickListener(this)
binding.sliderTimer.addOnChangeListener(this) binding.sliderTimer.addOnChangeListener(this)
binding.switchScrollTimer.setOnCheckedChangeListener(this)
settings.observeAsLiveData(
context = lifecycleScope.coroutineContext + Dispatchers.Default,
key = AppSettings.KEY_READER_AUTOSCROLL_SPEED,
valueProducer = { readerAutoscrollSpeed },
).observe(viewLifecycleOwner) {
binding.sliderTimer.value = it
}
findCallback()?.run { findCallback()?.run {
binding.sliderTimer.value = autoScrollSpeed binding.switchScrollTimer.isChecked = isAutoScrollEnabled
} }
} }
@@ -93,6 +112,16 @@ class ReaderConfigBottomSheet :
} }
} }
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
when (buttonView.id) {
R.id.switch_scroll_timer -> {
findCallback()?.isAutoScrollEnabled = isChecked
binding.labelTimer.isVisible = isChecked
binding.sliderTimer.isVisible = isChecked
}
}
}
override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) { override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) {
if (!isChecked) { if (!isChecked) {
return return
@@ -111,7 +140,9 @@ class ReaderConfigBottomSheet :
} }
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
findCallback()?.autoScrollSpeed = value if (fromUser) {
settings.readerAutoscrollSpeed = value
}
} }
override fun onActivityResult(uri: Uri?) { override fun onActivityResult(uri: Uri?) {
@@ -135,8 +166,7 @@ class ReaderConfigBottomSheet :
interface Callback { interface Callback {
@get:FloatRange(from = 0.0, to = 1.0) var isAutoScrollEnabled: Boolean
var autoScrollSpeed: Float
fun onReaderModeChanged(mode: ReaderMode) fun onReaderModeChanged(mode: ReaderMode)
} }

View File

@@ -103,51 +103,44 @@
android:text="@string/reader_mode_hint" android:text="@string/reader_mode_hint"
android:textAppearance="?attr/textAppearanceBodySmall" /> android:textAppearance="?attr/textAppearanceBodySmall" />
<androidx.constraintlayout.widget.ConstraintLayout <com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_scroll_timer"
android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall"
android:layout_marginTop="@dimen/margin_normal"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:ellipsize="end"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:singleLine="true"
android:text="@string/automatic_scroll"
android:textAppearance="?attr/textAppearanceButton"
android:textColor="?colorOnSurfaceVariant"
app:drawableStartCompat="@drawable/ic_timer" />
<TextView
android:id="@+id/label_timer"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal" android:layout_marginHorizontal="@dimen/margin_normal"
android:baselineAligned="false" android:layout_marginTop="@dimen/margin_small"
android:gravity="center_vertical" android:text="@string/speed"
android:minHeight="?android:listPreferredItemHeightSmall" android:textAppearance="?attr/textAppearanceBodySmall"
android:orientation="horizontal" android:visibility="gone"
android:paddingStart="?android:listPreferredItemPaddingStart" tools:visibility="visible" />
android:paddingEnd="?android:listPreferredItemPaddingEnd">
<TextView <com.google.android.material.slider.Slider
android:id="@+id/textView_timer" android:id="@+id/slider_timer"
android:layout_width="0dp" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:drawablePadding="?android:listPreferredItemPaddingStart" android:layout_marginHorizontal="@dimen/margin_normal"
android:ellipsize="end" android:contentDescription="@string/automatic_scroll"
android:singleLine="true" android:labelFor="@id/switch_scroll_timer"
android:text="@string/automatic_scroll" android:valueFrom="0.2"
android:textAppearance="?attr/textAppearanceButton" android:valueTo="1"
android:textColor="@color/list_item_text_color" android:visibility="gone"
app:drawableStartCompat="@drawable/ic_timer" app:labelBehavior="gone"
app:layout_constraintBottom_toBottomOf="parent" tools:visibility="visible" />
app:layout_constraintEnd_toStartOf="@id/slider_timer"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_default="wrap" />
<com.google.android.material.slider.Slider
android:id="@+id/slider_timer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_normal"
android:contentDescription="@string/automatic_scroll"
android:labelFor="@id/textView_timer"
android:valueFrom="0"
android:valueTo="1"
app:labelBehavior="floating"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/textView_timer"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_min="120dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
<org.koitharu.kotatsu.base.ui.widgets.ListItemTextView <org.koitharu.kotatsu.base.ui.widgets.ListItemTextView
android:id="@+id/button_color_filter" android:id="@+id/button_color_filter"

View File

@@ -430,4 +430,5 @@
<string name="settings_apply_restart_required">Please restart the application to apply these changes</string> <string name="settings_apply_restart_required">Please restart the application to apply these changes</string>
<string name="comics_archive_import_description">You can select one or more .cbz or .zip files, each file will be recognized as a separate manga.</string> <string name="comics_archive_import_description">You can select one or more .cbz or .zip files, each file will be recognized as a separate manga.</string>
<string name="folder_with_images_import_description">You can select a directory with archives or images. Each archive (or subdirectory) will be recognized as a chapter.</string> <string name="folder_with_images_import_description">You can select a directory with archives or images. Each archive (or subdirectory) will be recognized as a chapter.</string>
<string name="speed">Speed</string>
</resources> </resources>