diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt index bb73b89ec7..af7b299157 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt @@ -78,6 +78,11 @@ object SettingsReaderScreen : SearchableSettings { private fun getDisplayGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup { val fullscreenPref = readerPreferences.fullscreen() val fullscreen by fullscreenPref.collectAsState() + + val stabilizationNumberFormat = remember { NumberFormat.getPercentInstance() } + val stabilizationStrengthPref = readerPreferences.stabilization() + val stabilizationStrength by stabilizationStrengthPref.collectAsState() + return Preference.PreferenceGroup( title = stringResource(R.string.pref_category_display), preferenceItems = listOf( @@ -101,6 +106,17 @@ object SettingsReaderScreen : SearchableSettings { pref = fullscreenPref, title = stringResource(R.string.pref_fullscreen), ), + Preference.PreferenceItem.SliderPreference( + value = stabilizationStrength, + title = stringResource(R.string.pref_stabilization_strength), + subtitle = stabilizationNumberFormat.format(stabilizationStrength / 100f), + min = ReaderPreferences.STABILIZATION_STRENGTH_MIN, + max = ReaderPreferences.STABILIZATION_STRENGTH_MAX, + onValueChanged = { + stabilizationStrengthPref.set(it) + true + }, + ), Preference.PreferenceItem.SwitchPreference( pref = readerPreferences.cutoutShort(), title = stringResource(R.string.pref_cutout_short), diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index fdc22cbc95..5c80c19b66 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -10,6 +10,10 @@ import android.graphics.Color import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter import android.graphics.Paint +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager import android.net.Uri import android.os.Build import android.os.Bundle @@ -73,6 +77,7 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import eu.kanade.tachiyomi.util.stabilization.StabilizationProvider import eu.kanade.tachiyomi.util.system.applySystemAnimatorScale import eu.kanade.tachiyomi.util.system.hasDisplayCutout import eu.kanade.tachiyomi.util.system.isNightMode @@ -138,6 +143,11 @@ class ReaderActivity : BaseActivity() { private var loadingIndicator: ReaderProgressIndicator? = null + private lateinit var sensorManager: SensorManager + private lateinit var accelerometerEventListener: SensorEventListener + private lateinit var accelerometer: Sensor + private lateinit var stabilizationProvider: StabilizationProvider + var isScrollingThroughPages = false private set @@ -175,6 +185,7 @@ class ReaderActivity : BaseActivity() { config = ReaderConfig() initializeMenu() + initializeStabilization() // Finish when incognito mode is disabled preferences.incognitoMode().changes() @@ -239,6 +250,7 @@ class ReaderActivity : BaseActivity() { override fun onPause() { viewModel.flushReadTimer() super.onPause() + pauseStabilization() } /** @@ -249,6 +261,7 @@ class ReaderActivity : BaseActivity() { super.onResume() viewModel.restartReadTimer() setMenuVisibility(viewModel.state.value.menuVisible, animate = false) + startStabilization() } /** @@ -478,6 +491,42 @@ class ReaderActivity : BaseActivity() { setMenuVisibility(viewModel.state.value.menuVisible) } + /** + * Initializes screen shake stabilization. + */ + private fun initializeStabilization() { + stabilizationProvider = StabilizationProvider(binding.readerContainer, + readerPreferences.stabilization()) + sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager + accelerometerEventListener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent?) { + if (event != null) { + stabilizationProvider.stabilize(event) + } + } + + override fun onAccuracyChanged(p0: Sensor?, p1: Int) { } + } + + accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION)!! + } + + /** + * Starts screen shake stabilization. + */ + private fun startStabilization() { + sensorManager.registerListener(accelerometerEventListener, + accelerometer, SensorManager.SENSOR_DELAY_FASTEST) + stabilizationProvider.resetStabilization() + } + + /** + * Pauses screen shake stabilization. + */ + private fun pauseStabilization() { + sensorManager.unregisterListener(accelerometerEventListener) + } + private fun initBottomShortcuts() { // Reading mode with(binding.actionReadingMode) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt index 23a5beb137..f8af5e9ff0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt @@ -25,6 +25,8 @@ class ReaderPreferences( fun fullscreen() = preferenceStore.getBoolean("fullscreen", true) + fun stabilization() = preferenceStore.getInt("stabilization", 40) + fun cutoutShort() = preferenceStore.getBoolean("cutout_short", true) fun keepScreenOn() = preferenceStore.getBoolean("pref_keep_screen_on_key", true) @@ -146,6 +148,9 @@ class ReaderPreferences( const val WEBTOON_PADDING_MIN = 0 const val WEBTOON_PADDING_MAX = 25 + const val STABILIZATION_STRENGTH_MIN = 0 + const val STABILIZATION_STRENGTH_MAX = 100 + val TapZones = listOf( R.string.label_default, R.string.l_nav, diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/stabilization/StabilizationProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/util/stabilization/StabilizationProvider.kt new file mode 100644 index 0000000000..fae51f2780 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/stabilization/StabilizationProvider.kt @@ -0,0 +1,101 @@ +package eu.kanade.tachiyomi.util.stabilization + +import android.hardware.SensorEvent +import android.view.View +import tachiyomi.core.preference.Preference + +const val NANOSECONDS_TO_SECONDS = 1e-9f +const val BASE_VELOCITY_FRICTION = .2f +const val VELOCITY_SHIFT = .1f +const val BASE_POSITION_FRICTION = BASE_VELOCITY_FRICTION / 2 +const val POSITION_SHIFT = VELOCITY_SHIFT / 2 +const val MAX_ACCELERATION = 5.0f +const val ALPHA = 0.85f + +/** + * This class represents stabilization provider. + * @param targetView view to be stabilized. + * @param strengthPreference strength of stabilization. + * Note that strength will be changed according to settings even after object creation. + */ +class StabilizationProvider( + private val targetView: View, + private val strengthPreference: Preference, +) { + private val position: MutableList = MutableList(2) { 0f } + private val velocity: MutableList = MutableList(2) { 0f } + private val acceleration: MutableList = MutableList(2) { 0f } + private var timestamp: Long = 0L + + /** + * Stabilizes view according to provided event. + * @param sensorEvent acceleration sensor event. + */ + fun stabilize(sensorEvent: SensorEvent) { + val strength = strengthPreference.get() + + if (strength != 0) { + if (timestamp != 0L) { + val deltaTime = (sensorEvent.timestamp - timestamp) * NANOSECONDS_TO_SECONDS + + val velocityFriction = BASE_VELOCITY_FRICTION * ((100 - strength) / 100f) + VELOCITY_SHIFT + val positionFriction = BASE_POSITION_FRICTION * ((100 - strength) / 100f) + POSITION_SHIFT + + for (i in 0..1) { + acceleration[i] = alphaFilter( + rangeValue(sensorEvent.values[i], -MAX_ACCELERATION, MAX_ACCELERATION), + acceleration[i], + ) + + velocity[i] += acceleration[i] * deltaTime - velocityFriction * velocity[i] + velocity[i] = fixNanOrInfinite(velocity[i]) + + position[i] += velocity[i] * deltaTime * 10000 - positionFriction * position[i] + } + + targetView.translationX = -position[0] + targetView.translationY = position[1] + } + } else { + targetView.translationX = 0f + targetView.translationY = 0f + } + + timestamp = sensorEvent.timestamp + } + + /** + * Fully resets stabilization state. + */ + fun resetStabilization() { + position[0] = 0f + position[1] = 0f + + velocity[0] = 0f + velocity[1] = 0f + + acceleration[0] = 0f + acceleration[1] = 0f + + timestamp = 0 + + targetView.translationX = 0f + targetView.translationY = 0f + } + + private fun alphaFilter(current: Float, previous: Float): Float { + return previous + ALPHA * (current - previous) + } + + private fun rangeValue(value: Float, min: Float, max: Float): Float { + return value.coerceAtLeast(min).coerceAtMost(max) + } + + private fun fixNanOrInfinite(value: Float): Float { + return if (value.isFinite()) { + value + } else { + 0f + } + } +} diff --git a/i18n/src/main/res/values-ru/strings.xml b/i18n/src/main/res/values-ru/strings.xml index f1a7b494f6..dbfd6f19e8 100644 --- a/i18n/src/main/res/values-ru/strings.xml +++ b/i18n/src/main/res/values-ru/strings.xml @@ -131,6 +131,7 @@ Отправлять отчёты об ошибках Анимированные переходы страниц Полноэкранный режим + Сила стабилизации Масштабирование Не выключать экран Размер сетки diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 596de0f941..0827f3fd26 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -319,6 +319,7 @@ Fullscreen + Stabilization strength Show tap zones overlay Briefly show when reader is opened Split wide pages