Graduate Compose Reader to stable

This commit is contained in:
FooIbar committed 2024-07-28 17:29:50 +08:00
1 parent 01a1113a07
commit 1ecdc07655
49 files changed
+29 -5631

No files matched your search

-2
View File
@@ -94,8 +94,6 @@ Here is the libraries
- [libarchive](https://www.libarchive.org/)
- [jsoup](https://jsoup.org/)
Specially thanks [Tachiyomi](https://tachiyomi.org/) for its reader implementation!
# License
Copyright 2014-2019 Hippo Seven
-7
View File
@@ -169,7 +169,6 @@ android {
buildFeatures {
buildConfig = true
compose = true
viewBinding = true
}
namespace = "com.hippo.ehviewer"
@@ -223,15 +222,11 @@ dependencies {
// https://developer.android.com/jetpack/androidx/releases/paging
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.viewpager2)
// https://developer.android.com/jetpack/androidx/releases/room
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.work.runtime)
implementation(libs.photoview)
implementation(libs.material.motion.core)
implementation(libs.bundles.splitties)
@@ -246,8 +241,6 @@ dependencies {
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.accompanist.drawable.painter)
implementation(libs.insetter) // Dead Dependency
// implementation(libs.reorderable)
implementation(platform(libs.arrow.stack))
-5
View File
@@ -118,11 +118,6 @@
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<activity
android:name="eu.kanade.tachiyomi.ui.reader.ReaderActivity"
android:configChanges="screenSize"
android:launchMode="singleTask">
</activity>
<activity
android:name=".ui.SecurityActivity"
android:configChanges="screenSize"
@@ -175,9 +175,7 @@ object Settings : DataStorePreferences(null) {
var defaultDownloadLabel by stringOrNullPref("default_download_label", null)
var lastUpdateTime by longPref("last_update_time", BuildConfig.COMMIT_TIME)
// Tachiyomi Reader
var newReader by boolPref("new_compose_reader", false)
// Reader
val cropBorder = boolPref("crop_borders", false)
val colorFilter = boolPref("pref_color_filter_key", false)
val colorFilterValue = intPref("color_filter_value", 0)
@@ -70,7 +70,6 @@ import com.hippo.files.delete
import com.hippo.files.isDirectory
import com.hippo.files.openOutputStream
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlinx.coroutines.sync.Mutex
@@ -282,32 +281,14 @@ suspend fun removeFromFavorites(galleryInfo: BaseGalleryInfo) = doModifyFavorite
localFavorited = EhDB.containLocalFavorites(galleryInfo.gid),
)
context (Context, DestinationsNavigator)
context (DestinationsNavigator)
fun navToReader(
info: BaseGalleryInfo,
page: Int = -1,
) {
if (Settings.newReader) {
navigate(ReaderScreenDestination(ReaderScreenArgs.Gallery(info, page)))
} else {
val intent = Intent(this@Context, ReaderActivity::class.java)
intent.action = ReaderActivity.ACTION_EH
intent.putExtra(ReaderActivity.KEY_GALLERY_INFO, info)
intent.putExtra(ReaderActivity.KEY_PAGE, page)
startActivity(intent)
}
}
) = navigate(ReaderScreenDestination(ReaderScreenArgs.Gallery(info, page)))
fun DestinationsNavigator.navToReader(context: Context, uri: Uri) {
if (Settings.newReader) {
navigate(ReaderScreenDestination(ReaderScreenArgs.Archive(uri)))
} else {
val intent = Intent(context, ReaderActivity::class.java)
intent.action = Intent.ACTION_VIEW
intent.data = uri
context.startActivity(intent)
}
}
fun DestinationsNavigator.navToReader(uri: Uri) =
navigate(ReaderScreenDestination(ReaderScreenArgs.Archive(uri)))
context(DialogState, Context, DestinationsNavigator)
suspend fun doGalleryInfoAction(info: BaseGalleryInfo) {
@@ -17,7 +17,7 @@ inline fun <R> composing(
block: @Composable context(MainActivity, SnackbarHostState, DialogState, DestinationsNavigator, CoroutineScope)
() -> R,
) = block(
LocalContext.current.run { remember { findActivity<MainActivity>() } },
LocalContext.current.run { remember { findActivity() } },
LocalSnackBarHostState.current,
LocalDialogState.current,
navigator,
@@ -270,7 +270,7 @@ class MainActivity : EhActivity() {
val uri = intent.data ?: return@collect
when (uri.scheme) {
SCHEME_CONTENT, SCHEME_FILE -> {
navigator.navToReader(applicationContext, uri)
navigator.navToReader(uri)
}
else -> {
@@ -8,7 +8,6 @@ import androidx.compose.animation.ExitTransition
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.ComposeView
import androidx.navigation.NavBackStackEntry
import com.hippo.ehviewer.ui.theme.EhTheme
import com.hippo.ehviewer.ui.tools.DialogState
@@ -19,18 +18,6 @@ import soup.compose.material.motion.animation.materialSharedAxisXIn
import soup.compose.material.motion.animation.materialSharedAxisXOut
import soup.compose.material.motion.animation.rememberSlideDistance
inline fun ComposeView.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
EhTheme {
val dialogState = remember { DialogState() }
dialogState.Intercept()
CompositionLocalProvider(
LocalDialogState provides dialogState,
) {
content()
}
}
}
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
EhTheme {
ProvideVectorPainterCache {
@@ -38,7 +38,6 @@ import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.gallery.PageLoader2
import com.hippo.ehviewer.ui.main.plus
import eu.kanade.tachiyomi.data.preference.PreferenceValues.TappingInvertMode
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
@@ -46,6 +45,7 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.CONTINUOUS_VERTICAL
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.RIGHT_TO_LEFT
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.VERTICAL
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.WEBTOON
import eu.kanade.tachiyomi.ui.reader.setting.TappingInvertMode
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
import kotlinx.coroutines.CoroutineScope
@@ -89,12 +89,6 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues ->
Column(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection).verticalScroll(rememberScrollState()).padding(paddingValues)) {
if (AppConfig.isSnapshot) {
SwitchPreference(
title = "New Compose Reader [WIP!!!]",
value = Settings::newReader,
)
}
SwitchPreference(
title = stringResource(id = R.string.settings_advanced_save_parse_error_body),
summary = stringResource(id = R.string.settings_advanced_save_parse_error_body_summary),
@@ -1,24 +1,3 @@
@file:Suppress("NOTHING_TO_INLINE")
package com.hippo.ehviewer.util
import kotlin.reflect.KMutableProperty0
import kotlin.reflect.KProperty
fun <T> unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE, initializer)
interface Lazy<T> {
var value: T
}
fun <T> lazyMut(init: () -> KMutableProperty0<T>) = object : Lazy<T> {
private val lazy by lazy { init() }
override var value
get() = lazy.get()
set(value) = lazy.set(value)
}
inline operator fun <T> Lazy<T>.getValue(thisRef: Any?, property: KProperty<*>): T = value
inline operator fun <T> Lazy<T>.setValue(thisRef: Any?, prop: KProperty<*>?, newValue: T) {
value = newValue
}
@@ -1,150 +0,0 @@
package eu.kanade.tachiyomi.core.preference
import android.content.SharedPreferences
import android.content.SharedPreferences.Editor
import androidx.core.content.edit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
sealed class AndroidPreference<T>(
private val preferences: SharedPreferences,
private val keyFlow: Flow<String?>,
private val key: String,
private val defaultValue: T,
) : Preference<T> {
abstract fun read(preferences: SharedPreferences, key: String, defaultValue: T): T
abstract fun write(key: String, value: T): Editor.() -> Unit
override fun key(): String = key
override fun get(): T = read(preferences, key, defaultValue)
// Async, don't call get() immediately after this
override fun set(value: T) {
preferences.edit(action = write(key, value))
}
override fun isSet(): Boolean = preferences.contains(key)
override fun delete() {
preferences.edit {
remove(key)
}
}
override fun defaultValue(): T = defaultValue
override fun changes(): Flow<T> = keyFlow
.filter { it == key || it == null }
.onStart { emit("ignition") }
.map { get() }
.conflate()
override fun stateIn(scope: CoroutineScope): StateFlow<T> = changes().stateIn(scope, SharingStarted.Eagerly, get())
class StringPrimitive(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: String,
) : AndroidPreference<String>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: String): String = preferences.getString(key, defaultValue) ?: defaultValue
override fun write(key: String, value: String): Editor.() -> Unit = {
putString(key, value)
}
}
class LongPrimitive(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: Long,
) : AndroidPreference<Long>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: Long): Long = preferences.getLong(key, defaultValue)
override fun write(key: String, value: Long): Editor.() -> Unit = {
putLong(key, value)
}
}
class IntPrimitive(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: Int,
) : AndroidPreference<Int>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: Int): Int = preferences.getInt(key, defaultValue)
override fun write(key: String, value: Int): Editor.() -> Unit = {
putInt(key, value)
}
}
class FloatPrimitive(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: Float,
) : AndroidPreference<Float>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: Float): Float = preferences.getFloat(key, defaultValue)
override fun write(key: String, value: Float): Editor.() -> Unit = {
putFloat(key, value)
}
}
class BooleanPrimitive(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: Boolean,
) : AndroidPreference<Boolean>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: Boolean): Boolean = preferences.getBoolean(key, defaultValue)
override fun write(key: String, value: Boolean): Editor.() -> Unit = {
putBoolean(key, value)
}
}
class StringSetPrimitive(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: Set<String>,
) : AndroidPreference<Set<String>>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: Set<String>): Set<String> = preferences.getStringSet(key, defaultValue) ?: defaultValue
override fun write(key: String, value: Set<String>): Editor.() -> Unit = {
putStringSet(key, value)
}
}
class Object<T>(
preferences: SharedPreferences,
keyFlow: Flow<String?>,
key: String,
defaultValue: T,
val serializer: (T) -> String,
val deserializer: (String) -> T,
) : AndroidPreference<T>(preferences, keyFlow, key, defaultValue) {
override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T = try {
preferences.getString(key, null)?.let(deserializer) ?: defaultValue
} catch (e: Exception) {
defaultValue
}
override fun write(key: String, value: T): Editor.() -> Unit = {
putString(key, serializer(value))
}
}
}
@@ -1,55 +0,0 @@
package eu.kanade.tachiyomi.core.preference
import android.content.SharedPreferences
import com.hippo.ehviewer.Settings
import eu.kanade.tachiyomi.core.preference.AndroidPreference.BooleanPrimitive
import eu.kanade.tachiyomi.core.preference.AndroidPreference.FloatPrimitive
import eu.kanade.tachiyomi.core.preference.AndroidPreference.IntPrimitive
import eu.kanade.tachiyomi.core.preference.AndroidPreference.LongPrimitive
import eu.kanade.tachiyomi.core.preference.AndroidPreference.Object
import eu.kanade.tachiyomi.core.preference.AndroidPreference.StringPrimitive
import eu.kanade.tachiyomi.core.preference.AndroidPreference.StringSetPrimitive
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
object AndroidPreferenceStore : PreferenceStore {
private val sharedPreferences = Settings.prefs
private val keyFlow = sharedPreferences.keyFlow
override fun getString(key: String, defaultValue: String): Preference<String> = StringPrimitive(sharedPreferences, keyFlow, key, defaultValue)
override fun getLong(key: String, defaultValue: Long): Preference<Long> = LongPrimitive(sharedPreferences, keyFlow, key, defaultValue)
override fun getInt(key: String, defaultValue: Int): Preference<Int> = IntPrimitive(sharedPreferences, keyFlow, key, defaultValue)
override fun getFloat(key: String, defaultValue: Float): Preference<Float> = FloatPrimitive(sharedPreferences, keyFlow, key, defaultValue)
override fun getBoolean(key: String, defaultValue: Boolean): Preference<Boolean> = BooleanPrimitive(sharedPreferences, keyFlow, key, defaultValue)
override fun getStringSet(key: String, defaultValue: Set<String>): Preference<Set<String>> = StringSetPrimitive(sharedPreferences, keyFlow, key, defaultValue)
override fun <T> getObject(
key: String,
defaultValue: T,
serializer: (T) -> String,
deserializer: (String) -> T,
): Preference<T> = Object(
preferences = sharedPreferences,
keyFlow = keyFlow,
key = key,
defaultValue = defaultValue,
serializer = serializer,
deserializer = deserializer,
)
}
private val SharedPreferences.keyFlow
get() = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key: String? -> trySend(key) }
registerOnSharedPreferenceChangeListener(listener)
awaitClose {
unregisterOnSharedPreferenceChangeListener(listener)
}
}
@@ -1,26 +0,0 @@
package eu.kanade.tachiyomi.core.preference
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface Preference<T> {
fun key(): String
fun get(): T
fun set(value: T)
fun isSet(): Boolean
fun delete()
fun defaultValue(): T
fun changes(): Flow<T>
fun stateIn(scope: CoroutineScope): StateFlow<T>
}
inline fun <reified T, R : T> Preference<T>.getAndSet(crossinline block: (T) -> R) = set(block(get()))
@@ -1,39 +0,0 @@
package eu.kanade.tachiyomi.core.preference
interface PreferenceStore {
fun getString(key: String, defaultValue: String = ""): Preference<String>
fun getLong(key: String, defaultValue: Long = 0): Preference<Long>
fun getInt(key: String, defaultValue: Int = 0): Preference<Int>
fun getFloat(key: String, defaultValue: Float = 0f): Preference<Float>
fun getBoolean(key: String, defaultValue: Boolean = false): Preference<Boolean>
fun getStringSet(key: String, defaultValue: Set<String> = emptySet()): Preference<Set<String>>
fun <T> getObject(
key: String,
defaultValue: T,
serializer: (T) -> String,
deserializer: (String) -> T,
): Preference<T>
}
inline fun <reified T : Enum<T>> PreferenceStore.getEnum(
key: String,
defaultValue: T,
): Preference<T> = getObject(
key = key,
defaultValue = defaultValue,
serializer = { it.name },
deserializer = {
try {
enumValueOf(it)
} catch (e: IllegalArgumentException) {
defaultValue
}
},
)
@@ -1,25 +0,0 @@
package eu.kanade.tachiyomi.data.preference
const val MANGA_NON_COMPLETED = "manga_ongoing"
const val MANGA_HAS_UNREAD = "manga_fully_read"
const val MANGA_NON_READ = "manga_started"
/**
* This class stores the values for the preferences in the application.
*/
object PreferenceValues {
enum class TappingInvertMode(val shouldInvertHorizontal: Boolean = false, val shouldInvertVertical: Boolean = false) {
NONE,
HORIZONTAL(shouldInvertHorizontal = true),
VERTICAL(shouldInvertVertical = true),
BOTH(shouldInvertHorizontal = true, shouldInvertVertical = true),
}
enum class ReaderHideThreshold(val threshold: Int) {
HIGHEST(5),
HIGH(13),
LOW(31),
LOWEST(47),
}
}
@@ -26,9 +26,10 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowWidthSizeClass
import com.hippo.ehviewer.ui.tools.HapticFeedbackType
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.ui.tools.rememberHapticFeedback
import eu.kanade.tachiyomi.util.system.isTabletUi
import kotlin.math.roundToInt
@Composable
@@ -38,7 +39,8 @@ fun ChapterNavigator(
totalPages: Int,
onSliderValueChange: (Int) -> Unit,
) {
val isTabletUi = LocalConfiguration.current.isTabletUi()
val windowSizeClass = LocalWindowSizeClass.current
val isTabletUi = windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED
val horizontalPadding = if (isTabletUi) 24.dp else 16.dp
val layoutDirection = if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
val configuration = LocalConfiguration.current
@@ -1,790 +0,0 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.kanade.tachiyomi.ui.reader
import android.Manifest
import android.app.assist.AssistContent
import android.content.ClipData
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View.LAYER_TYPE_HARDWARE
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.core.content.FileProvider
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope
import arrow.core.raise.ensure
import com.hippo.ehviewer.BuildConfig
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.databinding.ReaderActivityBinding
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.archiveFile
import com.hippo.ehviewer.gallery.ArchivePageLoader
import com.hippo.ehviewer.gallery.EhPageLoader
import com.hippo.ehviewer.gallery.PageLoader2
import com.hippo.ehviewer.ui.EhActivity
import com.hippo.ehviewer.ui.reader.SettingsPager
import com.hippo.ehviewer.ui.reader.setCustomBrightnessValue
import com.hippo.ehviewer.ui.reader.setOrientation
import com.hippo.ehviewer.ui.reader.updateKeepScreenOn
import com.hippo.ehviewer.ui.screen.implicit
import com.hippo.ehviewer.ui.setMD3Content
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.awaitActivityResult
import com.hippo.ehviewer.util.displayPath
import com.hippo.ehviewer.util.getParcelableCompat
import com.hippo.ehviewer.util.getParcelableExtraCompat
import com.hippo.ehviewer.util.getValue
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.lazyMut
import com.hippo.ehviewer.util.requestPermission
import com.hippo.ehviewer.util.setValue
import com.hippo.files.toOkioPath
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.isNightMode
import eu.kanade.tachiyomi.util.system.logcat
import java.io.File
import kotlin.coroutines.resume
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import moe.tarsin.coroutines.runSuspendCatching
import okio.Path.Companion.toOkioPath
import splitties.systemservices.clipboardManager
class GalleryModel : ViewModel() {
var galleryProvider: PageLoader2? = null
override fun onCleared() {
super.onCleared()
galleryProvider?.stop()
galleryProvider = null
}
}
class ReaderActivity : EhActivity() {
lateinit var binding: ReaderActivityBinding
private var mAction: String? = null
private var mUri: Uri? = null
private var mGalleryInfo: BaseGalleryInfo? = null
private var mPage: Int = -1
private val vm: GalleryModel by viewModels()
private val dialogState = DialogState()
/**
* Whether the menu is currently visible.
*/
var menuVisible by mutableStateOf(false)
private var mGalleryProvider by lazyMut { vm::galleryProvider }
private var mCurrentIndex: Int
get() = mGalleryProvider!!.startPage
set(value) {
mGalleryProvider!!.startPage = value
}
private val galleryDetailUrl: String?
get() = mGalleryInfo?.run { EhUrl.getGalleryDetailUrl(gid, token) }
private suspend fun buildProvider(replace: Boolean = false): PageLoader2? {
mGalleryProvider?.let {
if (replace) it.stop() else return it
}
val provider = when (mAction) {
ACTION_EH -> {
mGalleryInfo?.let {
DownloadManager.getDownloadInfo(it.gid)?.archiveFile
?.let { file -> ArchivePageLoader(file, it.gid, mPage) }
?: EhPageLoader(it, mPage)
}
}
Intent.ACTION_VIEW -> {
mUri?.run {
ArchivePageLoader(toOkioPath()) { invalidator ->
runCatching {
dialogState.awaitInputText(
title = getString(R.string.archive_need_passwd),
hint = getString(R.string.archive_passwd),
) { text ->
ensure(text.isNotBlank()) { getString(R.string.passwd_cannot_be_empty) }
ensure(invalidator(text)) { getString(R.string.passwd_wrong) }
}
}.onFailure {
finish()
}.getOrThrow()
}
}
}
else -> null
}
if (provider == null) {
makeToast(R.string.error_reading_failed)
finish()
}
return provider
}
private fun handleIntent(intent: Intent?) {
intent ?: return
mAction = intent.action
mUri = intent.data
mGalleryInfo = intent.getParcelableExtraCompat(KEY_GALLERY_INFO)
mPage = intent.getIntExtra(KEY_PAGE, -1)
}
private fun onInit() {
handleIntent(intent)
}
private fun onRestore(savedInstanceState: Bundle) {
mAction = savedInstanceState.getString(KEY_ACTION)
mUri = savedInstanceState.getParcelableCompat(KEY_URI)
mGalleryInfo = savedInstanceState.getParcelableCompat(KEY_GALLERY_INFO)
mPage = savedInstanceState.getInt(KEY_PAGE, -1)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
lifecycleScope.launchIO {
buildProvider(true)?.let {
mGalleryProvider = it
it.start()
if (it.awaitReady()) {
withUIContext {
totalPage = it.size
viewer?.setGalleryProvider(it)
moveToPageIndex(0)
}
} else {
makeToast(R.string.error_reading_failed)
finish()
}
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(KEY_ACTION, mAction)
outState.putParcelable(KEY_URI, mUri)
if (mGalleryInfo != null) {
outState.putParcelable(KEY_GALLERY_INFO, mGalleryInfo)
}
outState.putInt(KEY_PAGE, mPage)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
onInit()
} else {
onRestore(savedInstanceState)
}
binding = ReaderActivityBinding.inflate(layoutInflater)
binding.dialogStub.setMD3Content {
val brightness by Settings.customBrightness.collectAsState()
val brightnessValue by Settings.customBrightnessValue.collectAsState()
val colorOverlayEnabled by Settings.colorFilter.collectAsState()
val colorOverlay by Settings.colorFilterValue.collectAsState()
val colorOverlayMode by Settings.colorFilterMode.collectAsState {
when (it) {
1 -> BlendMode.Multiply
2 -> BlendMode.Screen
3 -> BlendMode.Overlay
4 -> BlendMode.Lighten
5 -> BlendMode.Darken
else -> BlendMode.SrcOver
}
}
dialogState.Intercept()
val showSeekbar by Settings.showReaderSeekbar.collectAsState()
val sliderValueFlow = remember { MutableStateFlow(-1) }
LaunchedEffect(sliderValueFlow) {
sliderValueFlow.drop(1).debounce(200).collect {
moveToPageIndex(it)
}
}
ReaderAppBars(
visible = menuVisible,
isRtl = isRtl,
showSeekBar = showSeekbar,
currentPage = currentPage,
totalPages = totalPage,
onSliderValueChange = {
isScrollingThroughPages = true
sliderValueFlow.value = it
currentPage = it + 1
},
onClickSettings = {
lifecycleScope.launch {
dialogState.dialog { cont ->
fun dispose() = cont.resume(Unit)
var isColorFilter by remember { mutableStateOf(false) }
val scrim by animateColorAsState(
targetValue = if (isColorFilter) Color.Transparent else BottomSheetDefaults.ScrimColor,
label = "ScrimColor",
)
ModalBottomSheet(
onDismissRequest = { dispose() },
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
dragHandle = null,
// Yeah, I know color state should not be read here, but we have to do it...
scrimColor = scrim,
contentWindowInsets = { WindowInsets(0) },
) {
SettingsPager(modifier = Modifier.fillMaxSize()) { page ->
isColorFilter = page == 2
setMenuVisibility(!isColorFilter)
}
}
}
}
},
)
ReaderContentOverlay(
brightness = { brightnessValue }.takeIf { brightness && brightnessValue < 0 },
color = { colorOverlay }.takeIf { colorOverlayEnabled },
colorBlendMode = colorOverlayMode,
)
}
setContentView(binding.root)
lifecycleScope.launchIO {
buildProvider()?.let {
mGalleryProvider = it
it.start()
if (it.awaitReady()) {
withUIContext { setGallery() }
} else {
makeToast(R.string.error_reading_failed)
finish()
}
}
}
config = ReaderConfig()
initializeMenu()
}
fun retryPage(index: Int, orgImg: Boolean = false) {
mGalleryProvider?.retryPage(index, orgImg)
}
fun restartGalleryProvider() {
mGalleryProvider?.let {
it.restart()
viewer?.refreshAdapter()
}
}
private fun setGallery() {
if (mGalleryProvider?.isReady != true) return
totalPage = mGalleryProvider!!.size
viewer?.destroy()
viewer = ReadingModeType.toViewer(ReaderPreferences.defaultReadingMode().get(), this)
isRtl = viewer!!.isRtl
updateViewerInsets(ReaderPreferences.cutoutShort().get())
binding.viewerContainer.removeAllViews()
setOrientation(ReaderPreferences.defaultOrientationType().get())
binding.viewerContainer.addView(viewer?.getView())
viewer?.setGalleryProvider(mGalleryProvider!!)
moveToPageIndex(mCurrentIndex)
}
override fun onDestroy() {
super.onDestroy()
config = null
viewer?.destroy()
viewer = null
}
private suspend fun makeToast(text: String) = withUIContext {
Toast.makeText(this@ReaderActivity, text, Toast.LENGTH_SHORT).show()
}
private suspend fun makeToast(@StringRes resId: Int) = makeToast(getString(resId))
private fun provideImage(index: Int): Uri? = AppConfig.externalTempDir?.let { dir ->
mGalleryProvider?.run {
getImageFilename(index)?.let { filename ->
val file = File(dir, filename).takeIf { save(index, it.toOkioPath()) } ?: return null
FileProvider.getUriForFile(implicit<Context>(), BuildConfig.APPLICATION_ID + ".fileprovider", file)
}
}
}
fun shareImage(index: Int) {
lifecycleScope.launchIO {
val uri = provideImage(index) ?: return@launchIO makeToast(R.string.error_cant_save_image)
val intent = Intent()
intent.action = Intent.ACTION_SEND
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.putExtra(Intent.EXTRA_STREAM, uri)
mGalleryInfo?.run {
intent.putExtra(Intent.EXTRA_TEXT, EhUrl.getGalleryDetailUrl(gid, token))
}
val extension = FileUtils.getExtensionFromFilename(uri.path)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "image/jpeg"
intent.setDataAndType(uri, mimeType)
try {
startActivity(Intent.createChooser(intent, getString(R.string.share_image)))
} catch (e: Throwable) {
makeToast(R.string.error_cant_find_activity)
}
}
}
fun copyImage(index: Int) {
lifecycleScope.launchIO {
val uri = provideImage(index) ?: return@launchIO makeToast(R.string.error_cant_save_image)
val clipData = ClipData.newUri(contentResolver, "ehviewer", uri)
clipboardManager.setPrimaryClip(clipData)
makeToast(R.string.copied_to_clipboard)
}
}
fun saveImage(index: Int) {
mGalleryProvider ?: return
lifecycleScope.launchIO {
val granted = isAtLeastQ || requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
if (granted) {
val filename = mGalleryProvider!!.getImageFilename(index) ?: return@launchIO makeToast(R.string.error_cant_save_image)
val extension = FileUtils.getExtensionFromFilename(filename)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "image/jpeg"
val realPath: String
val resolver = contentResolver
val values = ContentValues()
values.put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
values.put(MediaStore.MediaColumns.DATE_ADDED, Clock.System.now().epochSeconds)
values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
if (isAtLeastQ) {
realPath = Environment.DIRECTORY_PICTURES + File.separator + AppConfig.APP_DIRNAME
values.put(MediaStore.MediaColumns.RELATIVE_PATH, realPath)
values.put(MediaStore.MediaColumns.IS_PENDING, 1)
} else {
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
val path = File(dir, AppConfig.APP_DIRNAME)
realPath = path.toString()
if (!FileUtils.ensureDirectory(path)) {
return@launchIO makeToast(R.string.error_cant_save_image)
}
values.put(MediaStore.MediaColumns.DATA, realPath + File.separator + filename)
}
val imageUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: return@launchIO makeToast(R.string.error_cant_save_image)
if (!mGalleryProvider!!.save(index, imageUri.toOkioPath())) {
try {
resolver.delete(imageUri, null, null)
} catch (e: Exception) {
logcat(e)
}
return@launchIO makeToast(R.string.error_cant_save_image)
} else if (isAtLeastQ) {
val contentValues = ContentValues()
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
resolver.update(imageUri, contentValues, null, null)
}
makeToast(getString(R.string.image_saved, realPath + File.separator + filename))
} else {
makeToast(R.string.permission_denied)
}
}
}
fun saveImageTo(index: Int) {
lifecycleScope.launchIO {
val filename = mGalleryProvider?.getImageFilename(index) ?: return@launchIO makeToast(R.string.error_cant_save_image)
val extension = FileUtils.getExtensionFromFilename(filename)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "image/jpeg"
runSuspendCatching {
awaitActivityResult(CreateDocument(mimeType), filename)?.let {
mGalleryProvider!!.save(index, it.toOkioPath())
makeToast(getString(R.string.image_saved, it.displayPath))
}
}.onFailure {
logcat(it)
makeToast(R.string.error_cant_find_activity)
}
}
}
override fun onProvideAssistContent(outContent: AssistContent) {
super.onProvideAssistContent(outContent)
val url = galleryDetailUrl
if (url != null) {
outContent.webUri = Uri.parse(url)
}
}
companion object {
const val ACTION_EH = "eh"
const val KEY_ACTION = "action"
const val KEY_URI = "uri"
const val KEY_GALLERY_INFO = "gallery_info"
const val KEY_PAGE = "page"
}
// Tachiyomi funcs
var isScrollingThroughPages = false
private set
/**
* Viewer used to display the pages (pager, webtoon, ...).
*/
var viewer: BaseViewer? = null
private set
private var config: ReaderConfig? = null
private val windowInsetsController by lazy {
WindowInsetsControllerCompat(
window,
binding.root,
)
}
/**
* Sets the visibility of the menu according to [visible].
*/
private fun setMenuVisibility(visible: Boolean) {
menuVisible = visible
if (visible) {
windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
if (ReaderPreferences.showPageNumber().get()) {
config?.setPageNumberVisibility(false)
}
} else {
if (ReaderPreferences.fullscreen().get()) {
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
windowInsetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
if (ReaderPreferences.showPageNumber().get()) {
config?.setPageNumberVisibility(true)
}
}
}
private var currentPage by mutableIntStateOf(-1)
private var totalPage by mutableIntStateOf(-1)
private var isRtl by mutableStateOf(false)
/**
* Initializes the reader menu. It sets up click listeners and the initial visibility.
*/
private fun initializeMenu() {
binding.pageNumber.setMD3Content {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodySmall,
) {
PageIndicatorText(
currentPage = currentPage,
totalPages = totalPage,
modifier = Modifier.navigationBarsPadding(),
)
}
}
// Set initial visibility
setMenuVisibility(menuVisible)
}
/**
* Dispatches a key event. If the viewer doesn't handle it, call the default implementation.
*/
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val handled = viewer?.handleKeyEvent(event) ?: false
return handled || super.dispatchKeyEvent(event)
}
/**
* Dispatches a generic motion event. If the viewer doesn't handle it, call the default
* implementation.
*/
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
val handled = viewer?.handleGenericMotionEvent(event) ?: false
return handled || super.dispatchGenericMotionEvent(event)
}
/**
* Moves the viewer to the given page [index]. It does nothing if the viewer is null or the
* page is not found.
*/
private fun moveToPageIndex(index: Int) {
val viewer = viewer ?: return
val page = mGalleryProvider?.pages?.getOrNull(index) ?: return
viewer.moveToPage(page)
}
/**
* Called from the viewer whenever a [page] is marked as active. It updates the values of the
* bottom menu and delegates the change to the presenter.
*/
fun onPageSelected(page: ReaderPage) {
// Set bottom page number
currentPage = page.number
mCurrentIndex = page.index
}
/**
* Called from the viewer whenever a [page] is long clicked. A bottom sheet with a list of
* actions to perform is shown.
*/
fun onPageLongTap(page: ReaderPage) {
lifecycleScope.launch {
dialogState.dialog {
ModalBottomSheet(
onDismissRequest = { it.cancel() },
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
contentWindowInsets = { WindowInsets(0) },
) {
ReaderPageSheet(page) { it.cancel() }
}
}
}
}
/**
* Called from the viewer to toggle the visibility of the menu. It's implemented on the
* viewer because each one implements its own touch and key events.
*/
fun toggleMenu() {
setMenuVisibility(!menuVisible)
}
/**
* Called from the viewer to hide the menu.
*/
fun hideMenu() {
if (menuVisible) {
setMenuVisibility(false)
}
}
/**
* Updates viewer insets depending on fullscreen reader preferences.
*/
fun updateViewerInsets(drawInCutout: Boolean) {
viewer?.getViewForInsets()?.applyInsetter {
type(navigationBars = true, statusBars = true, displayCutout = !drawInCutout) {
padding()
}
}
}
/**
* Class that handles the user preferences of the reader.
*/
private inner class ReaderConfig {
private fun getCombinedPaint(grayscale: Boolean, invertedColors: Boolean): Paint = Paint().apply {
colorFilter = ColorMatrixColorFilter(
ColorMatrix().apply {
if (grayscale) {
setSaturation(0f)
}
if (invertedColors) {
postConcat(
ColorMatrix(
floatArrayOf(
-1f, 0f, 0f, 0f, 255f,
0f, -1f, 0f, 0f, 255f,
0f, 0f, -1f, 0f, 255f,
0f, 0f, 0f, 1f, 0f,
),
),
)
}
},
)
}
/**
* Initializes the reader subscriptions.
*/
init {
ReaderPreferences.defaultReadingMode().changes()
.drop(1)
.onEach { setGallery() }
.launchIn(lifecycleScope)
ReaderPreferences.defaultOrientationType().changes()
.drop(1)
.onEach { setGallery() }
.launchIn(lifecycleScope)
ReaderPreferences.cropBorders().changes()
.onEach { restartGalleryProvider() }
.launchIn(lifecycleScope)
ReaderPreferences.readerTheme().changes()
.onEach { theme ->
binding.readerContainer.setBackgroundResource(
when (theme) {
0 -> android.R.color.white
2 -> R.color.reader_background_dark
3 -> automaticBackgroundColor()
else -> android.R.color.black
},
)
}
.launchIn(lifecycleScope)
ReaderPreferences.showPageNumber().changes()
.onEach { setPageNumberVisibility(it) }
.launchIn(lifecycleScope)
ReaderPreferences.cutoutShort().changes()
.onEach { updateViewerInsets(it) }
.launchIn(lifecycleScope)
ReaderPreferences.keepScreenOn().changes()
.onEach { window.updateKeepScreenOn(it) }
.launchIn(lifecycleScope)
ReaderPreferences.customBrightness().changes()
.onEach { setCustomBrightness(it) }
.launchIn(lifecycleScope)
merge(
ReaderPreferences.grayscale().changes(),
ReaderPreferences.invertedColors().changes(),
)
.onEach {
setLayerPaint(
ReaderPreferences.grayscale().get(),
ReaderPreferences.invertedColors().get(),
)
}
.launchIn(lifecycleScope)
}
/**
* Picks background color for [ReaderActivity] based on light/dark theme preference
*/
private fun automaticBackgroundColor(): Int = if (baseContext.isNightMode()) {
R.color.reader_background_dark
} else {
android.R.color.white
}
/**
* Sets the visibility of the bottom page indicator according to [visible].
*/
fun setPageNumberVisibility(visible: Boolean) {
binding.pageNumber.isVisible = visible
}
/**
* Sets the custom brightness overlay according to [enabled].
*/
@OptIn(FlowPreview::class)
private fun setCustomBrightness(enabled: Boolean) {
if (enabled) {
ReaderPreferences.customBrightnessValue().changes()
.sample(100)
.onEach { setCustomBrightnessValue(it) }
.launchIn(lifecycleScope)
} else {
setCustomBrightnessValue(0)
}
}
private fun setLayerPaint(grayscale: Boolean, invertedColors: Boolean) {
val paint = if (grayscale || invertedColors) {
getCombinedPaint(
grayscale,
invertedColors,
)
} else {
null
}
binding.viewerContainer.setLayerType(LAYER_TYPE_HARDWARE, paint)
}
}
}
@@ -1,112 +0,0 @@
package eu.kanade.tachiyomi.ui.reader
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewPropertyAnimator
import androidx.core.graphics.withScale
import androidx.core.graphics.withTranslation
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
import kotlin.math.abs
class ReaderNavigationOverlayView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {
private var viewPropertyAnimator: ViewPropertyAnimator? = null
private var navigation: ViewerNavigation? = null
fun setNavigation(navigation: ViewerNavigation, showOnStart: Boolean) {
val firstLaunch = this.navigation == null
this.navigation = navigation
invalidate()
if (isVisible || (!showOnStart && firstLaunch) || navigation is DisabledNavigation) {
return
}
viewPropertyAnimator = animate()
.alpha(1f)
.setDuration(FADE_DURATION)
.withStartAction {
isVisible = true
}
.withEndAction {
viewPropertyAnimator = null
}
viewPropertyAnimator?.start()
}
private val regionPaint = Paint()
private val textPaint = Paint().apply {
textAlign = Paint.Align.CENTER
color = Color.WHITE
textSize = 64f
}
private val textBorderPaint = Paint().apply {
textAlign = Paint.Align.CENTER
color = Color.BLACK
textSize = 64f
style = Paint.Style.STROKE
strokeWidth = 8f
}
override fun onDraw(canvas: Canvas) {
if (navigation == null) return
navigation?.regions?.forEach { region ->
val rect = region.rectF
// Scale rect from 1f,1f to screen width and height
canvas.withScale(width.toFloat(), height.toFloat()) {
regionPaint.color = context.getColor(region.type.colorRes)
drawRect(rect, regionPaint)
}
// Don't want scale anymore because it messes with drawText
// Translate origin to rect start (left, top)
canvas.withTranslation(x = (width * rect.left), y = (height * rect.top)) {
// Calculate center of rect width on screen
val x = width * (abs(rect.left - rect.right) / 2)
// Calculate center of rect height on screen
val y = height * (abs(rect.top - rect.bottom) / 2)
drawText(context.getString(region.type.nameRes), x, y, textBorderPaint)
drawText(context.getString(region.type.nameRes), x, y, textPaint)
}
}
}
override fun performClick(): Boolean {
super.performClick()
if (viewPropertyAnimator == null && isVisible) {
viewPropertyAnimator = animate()
.alpha(0f)
.setDuration(FADE_DURATION)
.withEndAction {
isVisible = false
viewPropertyAnimator = null
}
viewPropertyAnimator?.start()
}
return true
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
// Hide overlay if user start tapping or swiping
performClick()
return super.onTouchEvent(event)
}
}
private const val FADE_DURATION = 1000L
@@ -21,16 +21,12 @@ import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.util.findActivity
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import moe.tarsin.kt.andThen
@Composable
@@ -64,17 +60,3 @@ fun ReaderPageSheetMeta(
Item(icon = Icons.Default.Save, text = R.string.action_save_to, onClick = saveTo)
}
}
@Composable
fun ReaderPageSheet(page: ReaderPage, dismiss: () -> Unit) {
val activity = LocalContext.current.run { remember { findActivity<ReaderActivity>() } }
ReaderPageSheetMeta(
retry = { activity.retryPage(page.index) },
retryOrigin = { activity.retryPage(page.index, true) },
share = { activity.shareImage(page.index) },
copy = { activity.copyImage(page.index) },
save = { activity.saveImage(page.index) },
saveTo = { activity.saveImageTo(page.index) },
dismiss = dismiss,
)
}
@@ -1,116 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.setting
import eu.kanade.tachiyomi.core.preference.AndroidPreferenceStore
import eu.kanade.tachiyomi.core.preference.getEnum
import eu.kanade.tachiyomi.data.preference.PreferenceValues
object ReaderPreferences {
private val preferenceStore = AndroidPreferenceStore
// region General
fun pageTransitions() = preferenceStore.getBoolean("pref_enable_transitions_key", true)
fun doubleTapAnimSpeed() = preferenceStore.getInt("pref_double_tap_anim_speed", 500)
fun showPageNumber() = preferenceStore.getBoolean("pref_show_page_number_key", true)
fun showReaderSeekbar() = preferenceStore.getBoolean("pref_show_reader_seekbar", true)
fun doubleTapToZoom() = preferenceStore.getBoolean("pref_double_tap_to_zoom", true)
fun trueColor() = preferenceStore.getBoolean("pref_true_color_key", false)
fun fullscreen() = preferenceStore.getBoolean("fullscreen", true)
fun cutoutShort() = preferenceStore.getBoolean("cutout_short", true)
fun keepScreenOn() = preferenceStore.getBoolean("pref_keep_screen_on_key", true)
fun defaultReadingMode() = preferenceStore.getInt("pref_default_reading_mode_key", ReadingModeType.LEFT_TO_RIGHT.flagValue)
fun defaultOrientationType() = preferenceStore.getInt("pref_default_orientation_type_key", OrientationType.FREE.flagValue)
fun imageScaleType() = preferenceStore.getInt("pref_image_scale_type_key", 1)
fun zoomStart() = preferenceStore.getInt("pref_zoom_start_key", 1)
fun readerTheme() = preferenceStore.getInt("pref_reader_theme_key", 1)
fun alwaysShowChapterTransition() = preferenceStore.getBoolean("always_show_chapter_transition", true)
fun cropBorders() = preferenceStore.getBoolean("crop_borders", false)
fun navigateToPan() = preferenceStore.getBoolean("navigate_pan", true)
fun landscapeZoom() = preferenceStore.getBoolean("landscape_zoom", false)
fun cropBordersWebtoon() = preferenceStore.getBoolean("crop_borders_webtoon", false)
fun webtoonSidePadding() = preferenceStore.getInt("webtoon_side_padding", 0)
fun readerHideThreshold() = preferenceStore.getEnum("reader_hide_threshold", PreferenceValues.ReaderHideThreshold.LOW)
fun folderPerManga() = preferenceStore.getBoolean("create_folder_per_manga", false)
fun skipRead() = preferenceStore.getBoolean("skip_read", false)
fun skipFiltered() = preferenceStore.getBoolean("skip_filtered", true)
// endregion
// region Split two page spread
fun dualPageSplitPaged() = preferenceStore.getBoolean("pref_dual_page_split", false)
fun dualPageInvertPaged() = preferenceStore.getBoolean("pref_dual_page_invert", false)
fun dualPageSplitWebtoon() = preferenceStore.getBoolean("pref_dual_page_split_webtoon", false)
fun dualPageInvertWebtoon() = preferenceStore.getBoolean("pref_dual_page_invert_webtoon", false)
// endregion
// region Color filter
fun customBrightness() = preferenceStore.getBoolean("pref_custom_brightness_key", false)
fun customBrightnessValue() = preferenceStore.getInt("custom_brightness_value", 0)
fun colorFilter() = preferenceStore.getBoolean("pref_color_filter_key", false)
fun colorFilterValue() = preferenceStore.getInt("color_filter_value", 0)
fun colorFilterMode() = preferenceStore.getInt("color_filter_mode", 0)
fun grayscale() = preferenceStore.getBoolean("pref_grayscale", false)
fun invertedColors() = preferenceStore.getBoolean("pref_inverted_colors", false)
// endregion
// region Controls
fun readWithLongTap() = preferenceStore.getBoolean("reader_long_tap", true)
fun readWithVolumeKeys() = preferenceStore.getBoolean("reader_volume_keys", false)
fun readWithVolumeKeysInterval() = preferenceStore.getInt("reader_volume_keys_interval", 0)
fun readWithVolumeKeysInverted() = preferenceStore.getBoolean("reader_volume_keys_inverted", false)
fun navigationModePager() = preferenceStore.getInt("reader_navigation_mode_pager", 0)
fun navigationModeWebtoon() = preferenceStore.getInt("reader_navigation_mode_webtoon", 0)
fun pagerNavInverted() = preferenceStore.getInt("reader_tapping_inverted_2", 0)
fun webtoonNavInverted() = preferenceStore.getInt("reader_tapping_inverted_webtoon_2", 0)
fun showNavigationOverlayNewUser() = preferenceStore.getBoolean("reader_navigation_overlay_new_user", true)
fun showNavigationOverlayOnStart() = preferenceStore.getBoolean("reader_navigation_overlay_on_start", false)
fun wideColorGamut() = preferenceStore.getBoolean("wide_color_gamut", false)
// endregion
}
@@ -10,10 +10,6 @@ import com.hippo.ehviewer.icons.reader.LeftToRight
import com.hippo.ehviewer.icons.reader.RightToLeft
import com.hippo.ehviewer.icons.reader.Vertical
import com.hippo.ehviewer.icons.reader.Webtoon
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
@Stable
enum class ReadingModeType(
@@ -52,14 +48,5 @@ enum class ReadingModeType(
}
fun fromSpinner(position: Int?) = entries.find { value -> value.prefValue == position } ?: DEFAULT
fun toViewer(preference: Int?, activity: ReaderActivity): BaseViewer = when (fromPreference(preference)) {
LEFT_TO_RIGHT -> PagerViewer(activity)
RIGHT_TO_LEFT -> PagerViewer(activity, true)
VERTICAL -> PagerViewer(activity, isVertical = true)
WEBTOON -> WebtoonViewer(activity)
CONTINUOUS_VERTICAL -> WebtoonViewer(activity, isContinuous = false)
DEFAULT -> PagerViewer(activity)
}
}
}
@@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.ui.reader.setting
enum class TappingInvertMode(
val shouldInvertHorizontal: Boolean = false,
val shouldInvertVertical: Boolean = false,
) {
NONE,
HORIZONTAL(shouldInvertHorizontal = true),
VERTICAL(shouldInvertVertical = true),
BOTH(shouldInvertHorizontal = true, shouldInvertVertical = true),
}
@@ -1,55 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
/**
* Interface for implementing a viewer.
*/
interface BaseViewer {
val activity: ReaderActivity
val readerConfig: ReaderPageImageView.Config
val isRtl: Boolean
fun getViewForInsets() = getView()
/**
* Returns the view this viewer uses.
*/
fun getView(): View
/**
* Destroys this viewer. Called when leaving the reader or swapping viewers.
*/
fun destroy()
/**
* Tells this viewer to set the given [provider] as active.
*/
fun setGalleryProvider(provider: PageLoader)
/**
* Tells this viewer to move to the given [page].
*/
fun moveToPage(page: ReaderPage)
/**
* Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise.
*/
fun handleKeyEvent(event: KeyEvent): Boolean
/**
* Called from the containing activity when a generic motion [event] is received. It should
* return true if the event was handled, false otherwise.
*/
fun handleGenericMotionEvent(event: MotionEvent): Boolean
fun refreshAdapter()
}
@@ -1,75 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ViewConfiguration
import kotlin.math.abs
/**
* A custom gesture detector that also implements an on long tap confirmed, because the built-in
* one conflicts with the quick scale feature.
*/
open class GestureDetectorWithLongTap(
context: Context,
listener: Listener,
) : GestureDetector(context, listener) {
private val handler = Handler(Looper.getMainLooper())
private val slop = ViewConfiguration.get(context).scaledTouchSlop
private val longTapTime = ViewConfiguration.getLongPressTimeout().toLong()
private val doubleTapTime = ViewConfiguration.getDoubleTapTimeout().toLong()
private var downX = 0f
private var downY = 0f
private var lastUp = 0L
private var lastDownEvent: MotionEvent? = null
/**
* Runnable to execute when a long tap is confirmed.
*/
private val longTapFn = Runnable { listener.onLongTapConfirmed(lastDownEvent!!) }
override fun onTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastDownEvent?.recycle()
lastDownEvent = MotionEvent.obtain(ev)
// This is the key difference with the built-in detector. We have to ignore the
// event if the last up and current down are too close in time (double tap).
if (ev.downTime - lastUp > doubleTapTime) {
downX = ev.rawX
downY = ev.rawY
handler.postDelayed(longTapFn, longTapTime)
}
}
MotionEvent.ACTION_MOVE -> {
if (abs(ev.rawX - downX) > slop || abs(ev.rawY - downY) > slop) {
handler.removeCallbacks(longTapFn)
}
}
MotionEvent.ACTION_UP -> {
lastUp = ev.eventTime
handler.removeCallbacks(longTapFn)
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_DOWN -> {
handler.removeCallbacks(longTapFn)
}
}
return super.onTouchEvent(ev)
}
/**
* Custom listener to also include a long tap confirmed
*/
open class Listener : SimpleOnGestureListener() {
/**
* Notified when a long tap occurs with the initial on down [ev] that triggered it.
*/
open fun onLongTapConfirmed(ev: MotionEvent) {
}
}
}
@@ -1,58 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.util.AttributeSet
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.ui.theme.EhTheme
class ReaderErrorLayout @JvmOverloads constructor(
context: Context,
private val errorMsg: String? = null,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
private val onRetry: () -> Unit = {},
) : AbstractComposeView(context, attrs, defStyleAttr) {
init {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
}
@Composable
override fun Content() = EhTheme {
val defaultError = stringResource(id = R.string.decode_image_error)
Box(modifier = Modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT)) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = errorMsg ?: defaultError,
modifier = Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
)
Button(onClick = onRetry, modifier = Modifier.padding(8.dp)) {
Text(text = stringResource(id = R.string.action_retry))
}
}
}
}
}
private const val DEFAULT_ASPECT = 1 / 1.4125f
@@ -1,73 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.system.createReaderThemeContext
/**
* RecyclerView Adapter used by this [viewer] to where [PageLoader] updates are posted.
*/
class ReaderPageAdapter(
val viewer: BaseViewer,
private val isWebtoon: Boolean = false,
) : RecyclerView.Adapter<ReaderPageHolder>() {
/**
* List of currently set items.
*/
var items: List<ReaderPage> = emptyList()
private set
private var currentChapter: PageLoader? = null
/**
* Context that has been wrapped to use the correct theme values based on the
* current app theme and reader background color
*/
private var readerThemedContext = viewer.activity.createReaderThemeContext()
/**
* Updates this adapter with the given [chapter]. It handles setting a few pages of the
* next/previous chapter to allow seamless transitions.
*/
fun setChapters(chapter: PageLoader) {
currentChapter = chapter
items = chapter.pages
}
fun refresh() {
readerThemedContext = viewer.activity.createReaderThemeContext()
}
/**
* Returns the amount of items of the adapter.
*/
override fun getItemCount(): Int = items.size
/**
* Creates a new view holder for an item with the given [viewType].
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReaderPageHolder {
val view = ReaderPageImageView(readerThemedContext, isWebtoon = isWebtoon)
return ReaderPageHolder(view, viewer)
}
/**
* Binds an existing view [holder] with the item at the given [position].
*/
override fun onBindViewHolder(holder: ReaderPageHolder, position: Int) {
val item = items[position]
currentChapter!!.request(position)
holder.bind(item)
}
/**
* Recycles an existing view [holder] before adding it to the view pool.
*/
override fun onViewRecycled(holder: ReaderPageHolder) {
currentChapter?.cancelRequest(holder.bindingAdapterPosition)
holder.recycle()
}
}
@@ -1,208 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.content.res.Resources
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import androidx.recyclerview.widget.RecyclerView
import com.hippo.ehviewer.image.Image
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
import eu.kanade.tachiyomi.util.system.dpToPx
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* Holder of the reader for a single page of a chapter.
*
* @param frame the root view for this holder.
* @param viewer the page viewer.
* @constructor creates a new page holder.
*/
class ReaderPageHolder(
private val frame: ReaderPageImageView,
private val viewer: BaseViewer,
) : RecyclerView.ViewHolder(frame) {
/**
* Context getter because it's used often.
*/
val context: Context get() = frame.context
/**
* Loading progress bar to indicate the current progress.
* Needed to keep a minimum height size of the holder, otherwise the adapter would create more
* views to fill the screen, which is not wanted.
*/
private val progressIndicator = ReaderProgressIndicator(context)
/**
* Error layout to show when the image fails to load.
*/
private var errorLayout: ReaderErrorLayout? = null
/**
* Page of a chapter.
*/
private lateinit var page: ReaderPage
private val scope = CoroutineScope(Dispatchers.IO)
/**
* Subscription for status changes of the page.
*/
private var statusJob: Job? = null
init {
if (viewer is WebtoonViewer) {
val defaultHeight = (viewer.recycler.width * 1.4125).toInt()
frame.addView(progressIndicator, MATCH_PARENT, defaultHeight)
refreshLayoutParams()
} else {
frame.addView(progressIndicator)
frame.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
}
frame.onImageLoaded = ::onImageLoaded
frame.onScaleChanged = { viewer.activity.hideMenu() }
}
/**
* Binds the given [page] with this view holder, subscribing to its state.
*/
fun bind(page: ReaderPage) {
this.page = page
statusJob?.cancel()
statusJob = scope.launch(Dispatchers.Main) {
page.status.collectLatest {
processStatus(it)
}
}
refreshLayoutParams()
}
private fun refreshLayoutParams() {
if (viewer is WebtoonViewer) {
frame.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
if (!viewer.isContinuous) {
bottomMargin = 15.dpToPx
}
val margin = Resources.getSystem().displayMetrics.widthPixels * (viewer.config.sidePadding / 100f)
marginEnd = margin.toInt()
marginStart = margin.toInt()
}
}
}
fun onPageSelected(forward: Boolean) = frame.onPageSelected(forward)
fun panEnd(reverse: Boolean) = if (reverse) frame.panLeft() else frame.panRight()
/**
* Called when the view is recycled and added to the view pool.
*/
fun recycle() {
statusJob?.cancel()
statusJob = null
removeErrorLayout()
frame.recycle()
progressIndicator.reset()
}
/**
* Called when the status of the page changes.
*
* @param status the new status of the page.
*/
private suspend fun processStatus(status: Page.State) {
when (status) {
Page.State.QUEUE -> setQueued()
Page.State.LOAD_PAGE -> setLoading()
Page.State.DOWNLOAD_IMAGE -> {
setDownloading()
page.progressFlow.collectLatest { value -> progressIndicator.setProgress(value) }
}
Page.State.READY -> page.image?.let { setImage(it) } ?: setError()
Page.State.ERROR -> setError()
}
}
/**
* Called when the page is queued.
*/
private fun setQueued() {
progressIndicator.setProgress(0)
progressIndicator.show()
removeErrorLayout()
frame.recycle()
}
/**
* Called when the page is loading.
*/
private fun setLoading() {
progressIndicator.show()
removeErrorLayout()
}
/**
* Called when the page is downloading
*/
private fun setDownloading() {
progressIndicator.show()
removeErrorLayout()
}
/**
* Called when the page is ready.
*/
private fun setImage(image: Image) {
progressIndicator.setProgress(0)
frame.setImage(image, viewer.readerConfig)
}
/**
* Called when the page has an error.
*/
private fun setError() {
progressIndicator.hide()
initErrorLayout()
}
/**
* Called when the image is displayed.
*/
private fun onImageLoaded() {
progressIndicator.hide()
removeErrorLayout()
}
/**
* Initializes a button to retry pages.
*/
private fun initErrorLayout() {
if (errorLayout == null) {
errorLayout = ReaderErrorLayout(context, page.errorMsg) {
viewer.activity.retryPage(page.index)
}.also {
frame.addView(it)
}
}
}
/**
* Removes the decode error layout from the holder, if found.
*/
private fun removeErrorLayout() {
errorLayout?.let {
frame.removeView(it)
errorLayout = null
}
}
}
@@ -1,304 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.graphics.Bitmap
import android.graphics.PointF
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.compose.ui.graphics.toAndroidRect
import androidx.core.os.postDelayed
import androidx.core.view.isVisible
import coil3.BitmapImage
import coil3.DrawableImage
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.Companion.EASE_OUT_QUAD
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.Companion.SCALE_TYPE_CENTER_INSIDE
import com.hippo.ehviewer.image.Image
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonSubsamplingImageView
import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.view.isVisibleOnScreen
import io.getstream.photoview.PhotoView
/**
* A wrapper view for showing page image.
*
* Animated image will be drawn by [PhotoView] while [SubsamplingScaleImageView] will take non-animated image.
*
* @param isWebtoon if true, [WebtoonSubsamplingImageView] will be used instead of [SubsamplingScaleImageView]
* and [AppCompatImageView] will be used instead of [PhotoView]
*/
class ReaderPageImageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttrs: Int = 0,
@StyleRes defStyleRes: Int = 0,
private val isWebtoon: Boolean = false,
) : FrameLayout(context, attrs, defStyleAttrs, defStyleRes) {
private var pageView: View? = null
private var config: Config? = null
var onImageLoaded: (() -> Unit)? = null
var onScaleChanged: ((newScale: Float) -> Unit)? = null
/**
* For automatic background. Will be set as background color when [onImageLoaded] is called.
*/
var pageBackground: Drawable? = null
private fun onImageLoaded() {
onImageLoaded?.invoke()
background = pageBackground
}
fun onScaleChanged(newScale: Float) {
onScaleChanged?.invoke(newScale)
}
fun onPageSelected(forward: Boolean) {
with(pageView as? SubsamplingScaleImageView) {
if (this == null) return
if (isReady) {
landscapeZoom(forward)
} else {
setOnImageEventListener {
setupZoom(config)
landscapeZoom(forward)
this@ReaderPageImageView.onImageLoaded()
}
}
}
}
private fun SubsamplingScaleImageView.landscapeZoom(forward: Boolean) {
if (config != null && config!!.landscapeZoom && config!!.minimumScaleType == SCALE_TYPE_CENTER_INSIDE && sWidth > sHeight && scale == getMinScale()) {
handler?.postDelayed(500) {
val point = when (config!!.zoomStartPosition) {
ZoomStartPosition.LEFT -> if (forward) PointF(0F, 0F) else PointF(sWidth.toFloat(), 0F)
ZoomStartPosition.RIGHT -> if (forward) PointF(sWidth.toFloat(), 0F) else PointF(0F, 0F)
ZoomStartPosition.CENTER -> center.also { it?.y = 0F }
}
val targetScale = height.toFloat() / sHeight.toFloat()
animateScaleAndCenter(targetScale, point)!!
.withDuration(500)
.withEasing(SubsamplingScaleImageView.EASE_IN_OUT_QUAD)
.withInterruptible(true)
.start()
}
}
}
fun setImage(drawable: Image, config: Config) {
this.config = config
val image = drawable.innerImage ?: return
if (image is DrawableImage) {
prepareAnimatedImageView()
setAnimatedImage(image.drawable, config)
}
if (image is BitmapImage) {
prepareNonAnimatedImageView()
setNonAnimatedImage(image.bitmap, drawable.rect.toAndroidRect(), config)
}
}
fun recycle() = pageView?.let {
when (it) {
is SubsamplingScaleImageView -> it.recycle()
}
it.isVisible = false
}
/**
* Check whether the image can be panned.
* @param fn a function that returns the direction to check for
*/
private fun canPan(fn: (RectF) -> Float): Boolean {
(pageView as? SubsamplingScaleImageView)?.let { view ->
RectF().let {
view.getPanRemaining(it)
return fn(it) > 1
}
}
return false
}
/**
* Pans the image to the left by a screen's width worth.
*/
fun panLeft(): Boolean {
val canPan = canPan { it.left }
if (canPan) {
pan { center, view -> center.also { it.x -= view.width / view.scale } }
}
return canPan
}
/**
* Pans the image to the right by a screen's width worth.
*/
fun panRight(): Boolean {
val canPan = canPan { it.right }
if (canPan) {
pan { center, view -> center.also { it.x += view.width / view.scale } }
}
return canPan
}
/**
* Pans the image.
* @param fn a function that computes the new center of the image
*/
private fun pan(fn: (PointF, SubsamplingScaleImageView) -> PointF) {
(pageView as? SubsamplingScaleImageView)?.let { view ->
val target = fn(view.center ?: return, view)
view.animateCenter(target)!!
.withEasing(EASE_OUT_QUAD)
.withDuration(250)
.withInterruptible(true)
.start()
}
}
private fun prepareNonAnimatedImageView() {
if (pageView is SubsamplingScaleImageView) return
removeView(pageView)
pageView = if (isWebtoon) {
WebtoonSubsamplingImageView(context)
} else {
SubsamplingScaleImageView(context)
}.apply {
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER)
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
setOnStateChangedListener(
object : SubsamplingScaleImageView.OnStateChangedListener {
override fun onScaleChanged(newScale: Float, origin: Int) {
this@ReaderPageImageView.onScaleChanged(newScale)
}
override fun onCenterChanged(newCenter: PointF?, origin: Int) {
// Not used
}
},
)
}
addView(pageView, MATCH_PARENT, MATCH_PARENT)
}
private fun SubsamplingScaleImageView.setupZoom(config: Config?) {
// 5x zoom
maxScale = scale * MAX_ZOOM_SCALE
setDoubleTapZoomScale(scale * 2)
when (config?.zoomStartPosition) {
ZoomStartPosition.LEFT -> setScaleAndCenter(scale, PointF(0F, 0F))
ZoomStartPosition.RIGHT -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0F))
ZoomStartPosition.CENTER -> setScaleAndCenter(scale, center.also { it?.y = 0F })
null -> {}
}
}
private fun setNonAnimatedImage(
image: Bitmap,
bounds: Rect,
config: Config,
) = (pageView as? SubsamplingScaleImageView)?.apply {
setDoubleTapZoomDuration(config.zoomDuration.getSystemScaledDuration())
setMinimumScaleType(config.minimumScaleType)
setMinimumDpi(1) // Just so that very small image will be fit for initial load
setOnImageEventListener {
setupZoom(config)
if (isVisibleOnScreen()) landscapeZoom(true)
this@ReaderPageImageView.onImageLoaded()
}
setImage(image, bounds)
isVisible = true
}
private fun prepareAnimatedImageView() {
if (pageView is AppCompatImageView) return
removeView(pageView)
pageView = if (isWebtoon) {
AppCompatImageView(context)
} else {
PhotoView(context)
}.apply {
adjustViewBounds = true
if (this is PhotoView) {
setScaleLevels(1F, 2F, MAX_ZOOM_SCALE)
// Force 2 scale levels on double tap
setOnDoubleTapListener(
object : GestureDetector.SimpleOnGestureListener() {
override fun onDoubleTap(e: MotionEvent): Boolean {
if (ReaderPreferences.doubleTapToZoom().get()) {
if (scale > 1F) {
setScale(1F, e.x, e.y, true)
} else {
setScale(2F, e.x, e.y, true)
}
}
return true
}
},
)
setOnScaleChangeListener { _, _, _ ->
this@ReaderPageImageView.onScaleChanged(scale)
}
}
}
addView(pageView, MATCH_PARENT, MATCH_PARENT)
}
private fun setAnimatedImage(
image: Drawable,
config: Config,
) = (pageView as? AppCompatImageView)?.apply {
if (this is PhotoView) {
setZoomTransitionDuration(config.zoomDuration.getSystemScaledDuration())
}
setImageDrawable(image)
(image as? Animatable)?.start()
isVisible = true
this@ReaderPageImageView.onImageLoaded()
}
private fun Int.getSystemScaledDuration(): Int = (this * context.animatorDurationScale).toInt().coerceAtLeast(1)
/**
* All of the config except [zoomDuration] will only be used for non-animated image.
*/
data class Config(
val zoomDuration: Int,
val minimumScaleType: Int = SCALE_TYPE_CENTER_INSIDE,
val cropBorders: Boolean = false,
val zoomStartPosition: ZoomStartPosition = ZoomStartPosition.CENTER,
val landscapeZoom: Boolean = false,
)
enum class ZoomStartPosition {
LEFT,
CENTER,
RIGHT,
}
}
private const val MAX_ZOOM_SCALE = 5F
@@ -1,61 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.util.AttributeSet
import androidx.annotation.IntRange
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.view.isVisible
import com.hippo.ehviewer.ui.theme.EhTheme
class ReaderProgressIndicator @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : AbstractComposeView(context, attrs, defStyleAttr) {
init {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool)
}
private var progress by mutableFloatStateOf(0f)
private var visible by mutableStateOf(false)
@Composable
override fun Content() {
EhTheme {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
if (visible) CombinedCircularProgressIndicator(progress = progress)
}
}
}
fun show() {
visible = true
isVisible = true
}
fun hide() {
visible = false
isVisible = false
}
fun reset() {
progress = 0f
visible = false
isVisible = true
}
fun setProgress(@IntRange(from = 0, to = 100) progress: Int) {
this.progress = progress / 100f
}
}
@@ -1,102 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import eu.kanade.tachiyomi.core.preference.Preference
import eu.kanade.tachiyomi.data.preference.PreferenceValues.TappingInvertMode
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
/**
* Common configuration for all viewers.
*/
abstract class ViewerConfig(readerPreferences: ReaderPreferences, private val scope: CoroutineScope) {
var imagePropertyChangedListener: (() -> Unit)? = null
var navigationModeChangedListener: (() -> Unit)? = null
var tappingInverted = TappingInvertMode.NONE
var longTapEnabled = true
var usePageTransitions = false
var doubleTapAnimDuration = 500
var volumeKeysEnabled = false
var volumeKeysInterval = 0
var volumeKeysInverted = false
var trueColor = false
var alwaysShowChapterTransition = true
var navigationMode = 0
protected set
var forceNavigationOverlay = false
var navigationOverlayOnStart = false
var dualPageSplit = false
protected set
var dualPageInvert = false
protected set
abstract var navigator: ViewerNavigation
protected set
init {
readerPreferences.readWithLongTap()
.register({ longTapEnabled = it })
readerPreferences.pageTransitions()
.register({ usePageTransitions = it })
readerPreferences.doubleTapAnimSpeed()
.register({ doubleTapAnimDuration = it })
readerPreferences.readWithVolumeKeys()
.register({ volumeKeysEnabled = it })
readerPreferences.readWithVolumeKeysInterval()
.register({ volumeKeysInterval = it })
readerPreferences.readWithVolumeKeysInverted()
.register({ volumeKeysInverted = it })
readerPreferences.trueColor()
.register({ trueColor = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.alwaysShowChapterTransition()
.register({ alwaysShowChapterTransition = it })
forceNavigationOverlay = readerPreferences.showNavigationOverlayNewUser().get()
if (forceNavigationOverlay) {
readerPreferences.showNavigationOverlayNewUser().set(false)
}
readerPreferences.showNavigationOverlayOnStart()
.register({ navigationOverlayOnStart = it })
}
protected abstract fun defaultNavigation(): ViewerNavigation
abstract fun updateNavigation(navigationMode: Int)
fun <T> Preference<T>.register(
valueAssignment: (T) -> Unit,
onChanged: (T) -> Unit = {},
) {
changes()
.onEach { valueAssignment(it) }
.distinctUntilChanged()
.onEach { onChanged(it) }
.launchIn(scope)
}
fun <T> Flow<T>.register(
valueAssignment: (T) -> Unit,
onChanged: (T) -> Unit = {},
) = onEach { valueAssignment(it) }
.distinctUntilChanged()
.onEach { onChanged(it) }
.launchIn(scope)
}
@@ -1,10 +1,9 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.graphics.PointF
import android.graphics.RectF
import androidx.annotation.StringRes
import com.hippo.ehviewer.R
import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.ui.reader.setting.TappingInvertMode
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.EdgeNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.KindlishNavigation
@@ -26,32 +25,19 @@ abstract class ViewerNavigation {
val rectF: RectF,
val type: NavigationRegion,
) {
fun invert(invertMode: PreferenceValues.TappingInvertMode): Region {
if (invertMode == PreferenceValues.TappingInvertMode.NONE) return this
fun invert(invertMode: TappingInvertMode): Region {
if (invertMode == TappingInvertMode.NONE) return this
return this.copy(
rectF = this.rectF.invert(invertMode),
)
}
}
private val constantMenuRegion: RectF = RectF(0f, 0f, 1f, 0.05f)
protected abstract val originalRegions: List<Region>
val regions get() = originalRegions.map { it.invert(invertMode) }
var invertMode: PreferenceValues.TappingInvertMode = PreferenceValues.TappingInvertMode.NONE
fun getAction(pos: PointF): NavigationRegion {
val x = pos.x
val y = pos.y
val region = regions.find { it.rectF.contains(x, y) }
return when {
region != null -> region.type
constantMenuRegion.contains(x, y) -> NavigationRegion.MENU
else -> NavigationRegion.MENU
}
}
var invertMode = TappingInvertMode.NONE
companion object {
fun fromPreference(value: Int, isVertical: Boolean) = when (value) {
@@ -1,135 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import eu.kanade.tachiyomi.data.preference.PreferenceValues.TappingInvertMode
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerConfig
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.EdgeNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.KindlishNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
/**
* Configuration used by pager viewers.
*/
class PagerConfig(
private val viewer: PagerViewer,
scope: CoroutineScope,
readerPreferences: ReaderPreferences = ReaderPreferences,
) : ViewerConfig(readerPreferences, scope) {
var theme = readerPreferences.readerTheme().get()
private set
var automaticBackground = false
private set
var dualPageSplitChangedListener: ((Boolean) -> Unit)? = null
var imageScaleType = 1
private set
var imageZoomType = ReaderPageImageView.ZoomStartPosition.LEFT
private set
var imageCropBorders = false
private set
var navigateToPan = false
private set
var landscapeZoom = false
private set
init {
readerPreferences.readerTheme()
.register(
{
theme = it
automaticBackground = it == 3
},
{ imagePropertyChangedListener?.invoke() },
)
readerPreferences.imageScaleType()
.register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.zoomStart()
.register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() })
readerPreferences.navigateToPan()
.register({ navigateToPan = it })
readerPreferences.landscapeZoom()
.register({ landscapeZoom = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.navigationModePager()
.register({ navigationMode = it }, { updateNavigation(navigationMode) })
readerPreferences.pagerNavInverted().changes().map { TappingInvertMode.entries[it] }
.register({ tappingInverted = it }, { navigator.invertMode = it })
readerPreferences.pagerNavInverted().changes()
.drop(1)
.onEach { navigationModeChangedListener?.invoke() }
.launchIn(scope)
readerPreferences.dualPageSplitPaged()
.register(
{ dualPageSplit = it },
{
imagePropertyChangedListener?.invoke()
dualPageSplitChangedListener?.invoke(it)
},
)
readerPreferences.dualPageInvertPaged()
.register({ dualPageInvert = it }, { imagePropertyChangedListener?.invoke() })
}
private fun zoomTypeFromPreference(value: Int) {
imageZoomType = when (value) {
// Auto
1 -> when {
viewer.isRtl -> ReaderPageImageView.ZoomStartPosition.RIGHT
viewer.isVertical -> ReaderPageImageView.ZoomStartPosition.CENTER
else -> ReaderPageImageView.ZoomStartPosition.LEFT
}
// Left
2 -> ReaderPageImageView.ZoomStartPosition.LEFT
// Right
3 -> ReaderPageImageView.ZoomStartPosition.RIGHT
// Center
else -> ReaderPageImageView.ZoomStartPosition.CENTER
}
}
override var navigator: ViewerNavigation = defaultNavigation()
set(value) {
field = value.also { it.invertMode = this.tappingInverted }
}
override fun defaultNavigation(): ViewerNavigation = when {
viewer.isVertical -> LNavigation()
else -> RightAndLeftNavigation()
}
override fun updateNavigation(navigationMode: Int) {
navigator = when (navigationMode) {
0 -> defaultNavigation()
1 -> LNavigation()
2 -> KindlishNavigation()
3 -> EdgeNavigation()
4 -> RightAndLeftNavigation()
5 -> DisabledNavigation()
else -> defaultNavigation()
}
navigationModeChangedListener?.invoke()
}
}
@@ -1,95 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.annotation.SuppressLint
import android.content.Context
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.widget.FrameLayout
import androidx.viewpager2.widget.ViewPager2
import eu.kanade.tachiyomi.ui.reader.viewer.GestureDetectorWithLongTap
/**
* Frame layout that wraps a [ViewPager2] to handle touch events because [ViewPager2] is final.
*/
class PagerFrame(context: Context) : FrameLayout(context) {
/**
* Tap listener function to execute when a tap is detected.
*/
var tapListener: ((MotionEvent) -> Unit)? = null
/**
* Long tap listener function to execute when a long tap is detected.
*/
var longTapListener: ((MotionEvent) -> Boolean)? = null
/**
* Gesture listener that implements tap and long tap events.
*/
private val gestureListener = object : GestureDetectorWithLongTap.Listener() {
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
tapListener?.invoke(ev)
return true
}
override fun onLongTapConfirmed(ev: MotionEvent) {
val listener = longTapListener
if (listener != null && listener.invoke(ev)) {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
}
}
/**
* Gesture detector which handles motion events.
*/
private val gestureDetector = GestureDetectorWithLongTap(context, gestureListener)
/**
* Whether the gesture detector is currently enabled.
*/
private var isGestureDetectorEnabled = true
/**
* Dispatches a touch event.
*/
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
val handled = super.dispatchTouchEvent(ev)
if (isGestureDetectorEnabled) {
gestureDetector.onTouchEvent(ev)
}
return handled
}
/**
* Whether the given [ev] should be intercepted. Only used to prevent crashes when child
* views manipulate [requestDisallowInterceptTouchEvent].
*/
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean = try {
super.onInterceptTouchEvent(ev)
} catch (e: IllegalArgumentException) {
false
}
/**
* Handles a touch event. Only used to prevent crashes when child views manipulate
* [requestDisallowInterceptTouchEvent].
*/
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(ev: MotionEvent): Boolean = try {
super.onTouchEvent(ev)
} catch (e: NullPointerException) {
false
} catch (e: IndexOutOfBoundsException) {
false
} catch (e: IllegalArgumentException) {
false
}
/**
* Enables or disables the gesture detector.
*/
fun setGestureDetectorEnabled(enabled: Boolean) {
isGestureDetectorEnabled = enabled
}
}
@@ -1,390 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.graphics.PointF
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup.LayoutParams
import androidx.core.view.get
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.hippo.ehviewer.R
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageAdapter
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageHolder
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
/**
* Implementation of a [BaseViewer] to display pages with a [ViewPager2].
*/
class PagerViewer(
override val activity: ReaderActivity,
override val isRtl: Boolean = false,
val isVertical: Boolean = false,
) : BaseViewer {
private val scope = MainScope()
/**
* View pager used by this viewer.
*/
private val pager = ViewPager2(activity).apply {
if (isRtl) {
layoutDirection = ViewPager2.LAYOUT_DIRECTION_RTL
} else if (isVertical) {
orientation = ViewPager2.ORIENTATION_VERTICAL
}
}
private val frame = PagerFrame(activity)
/**
* Configuration used by the pager, like allow taps, scale mode on images, page transitions...
*/
private val config = PagerConfig(this, scope)
/**
* Adapter of the pager.
*/
private val adapter = ReaderPageAdapter(this)
/**
* Currently active item. It can be a chapter page or a chapter transition.
*/
private var currentPage: ReaderPage? = null
/**
* Viewer chapters to set when the pager enters idle mode. Otherwise, if the view was settling
* or dragging, there'd be a noticeable and annoying jump.
*/
private var awaitingIdleViewerChapters: PageLoader? = null
/**
* Whether the view pager is currently in idle mode. It sets the awaiting chapters if setting
* this field to true.
*/
private var isIdle = true
set(value) {
field = value
if (value) {
awaitingIdleViewerChapters?.let { viewerChapters ->
setChaptersInternal(viewerChapters)
awaitingIdleViewerChapters = null
}
}
}
private var longPressed = false
init {
pager.isVisible = false // Don't layout the pager yet
pager.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
pager.clipToPadding = false
pager.isFocusable = false
pager.offscreenPageLimit = 1
pager.id = R.id.reader_pager
pager.adapter = adapter
pager.registerOnPageChangeCallback(
object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
if (activity.isScrollingThroughPages.not()) {
activity.hideMenu()
}
onPageChange(position)
}
override fun onPageScrollStateChanged(state: Int) {
isIdle = state == ViewPager2.SCROLL_STATE_IDLE
}
},
)
frame.tapListener = { event ->
val windowOffset = IntArray(2)
activity.window.decorView.getLocationOnScreen(windowOffset)
val pos = PointF(
(event.rawX - windowOffset[0]) / pager.width,
(event.rawY - windowOffset[1]) / pager.height,
)
val navigator = config.navigator
when (navigator.getAction(pos)) {
NavigationRegion.MENU -> activity.toggleMenu()
NavigationRegion.NEXT -> moveToNext()
NavigationRegion.PREV -> moveToPrevious()
NavigationRegion.RIGHT -> moveRight()
NavigationRegion.LEFT -> moveLeft()
}
}
frame.longTapListener = f@{
if (activity.menuVisible || config.longTapEnabled) {
val item = adapter.items.getOrNull(pager.currentItem)
if (item is ReaderPage) {
activity.onPageLongTap(item)
return@f true
}
}
false
}
config.imagePropertyChangedListener = {
refreshAdapter()
}
config.navigationModeChangedListener = {
val showOnStart = config.navigationOverlayOnStart || config.forceNavigationOverlay
activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart)
}
frame.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
frame.addView(pager)
}
override val readerConfig
get() = ReaderPageImageView.Config(
zoomDuration = config.doubleTapAnimDuration,
minimumScaleType = config.imageScaleType,
cropBorders = config.imageCropBorders,
zoomStartPosition = config.imageZoomType,
landscapeZoom = config.landscapeZoom,
)
override fun destroy() {
scope.cancel()
}
/**
* Returns the view this viewer uses.
*/
override fun getView(): View = frame
/**
* Returns the [ReaderPageHolder] for the provided page
*/
private fun getPageHolder(position: Int): ReaderPageHolder? =
(pager[0] as RecyclerView).findViewHolderForAdapterPosition(position) as ReaderPageHolder?
/**
* Called when a new [ReaderPage] is marked as active
*/
private fun onPageChange(position: Int) {
val page = adapter.items.getOrNull(position)
if (page != null && currentPage != page) {
val forward = page.number > (currentPage?.number ?: 0)
currentPage = page
onReaderPageSelected(page, forward)
}
}
/**
* Called when a [ReaderPage] is marked as active. It notifies the
* activity of the change and requests the preload of the next chapter if this is the last page.
*/
private fun onReaderPageSelected(page: ReaderPage, forward: Boolean) {
activity.onPageSelected(page)
// Notify holder of page change
getPageHolder(page.index)?.onPageSelected(forward)
}
/**
* Tells this viewer to set the given [provider] as active. If the pager is currently idle,
* it sets the chapters immediately, otherwise they are saved and set when it becomes idle.
*/
override fun setGalleryProvider(provider: PageLoader) {
if (isIdle) {
setChaptersInternal(provider)
} else {
awaitingIdleViewerChapters = provider
}
}
/**
* Sets the active [chapters] on this pager.
*/
private fun setChaptersInternal(chapters: PageLoader) {
logcat { "setChaptersInternal" }
adapter.setChapters(chapters)
refreshAdapter(0)
// Layout the pager once a chapter is being set
if (pager.isGone) {
logcat { "Pager first layout" }
pager.isVisible = true
}
}
/**
* Tells this viewer to move to the given [page].
*/
override fun moveToPage(page: ReaderPage) {
logcat { "moveToPage ${page.number}" }
val position = adapter.items.indexOf(page)
if (position != -1) {
val currentPosition = pager.currentItem
pager.setCurrentItem(position, true)
// manually call onPageChange since ViewPager listener is not triggered in this case
if (currentPosition == position) {
onPageChange(position)
}
} else {
logcat { "Page $page not found in adapter" }
}
}
/**
* Moves to the next page.
*/
private fun moveToNext() {
val currentItem = pager.currentItem
if (currentItem != adapter.itemCount - 1) {
val holder = getPageHolder(currentItem)
if (!config.navigateToPan || holder?.panEnd(isRtl) != true) {
pager.setCurrentItem(currentItem + 1, config.usePageTransitions)
}
}
}
/**
* Moves to the previous page.
*/
private fun moveToPrevious() {
val currentItem = pager.currentItem
if (currentItem != 0) {
val holder = getPageHolder(currentItem)
if (!config.navigateToPan || holder?.panEnd(!isRtl) != true) {
pager.setCurrentItem(currentItem - 1, config.usePageTransitions)
}
}
}
/**
* Moves to the page at the right.
*/
private fun moveRight() {
if (isRtl) {
moveToPrevious()
} else {
moveToNext()
}
}
/**
* Moves to the page at the left.
*/
private fun moveLeft() {
if (isRtl) {
moveToNext()
} else {
moveToPrevious()
}
}
/**
* Moves to the page at the top (or previous).
*/
private fun moveUp() {
moveToPrevious()
}
/**
* Moves to the page at the bottom (or next).
*/
private fun moveDown() {
moveToNext()
}
override fun refreshAdapter() {
refreshAdapter(pager.currentItem)
}
/**
* Resets the adapter in order to recreate all the views. Used when a image configuration is
* changed.
*/
private fun refreshAdapter(currentItem: Int) {
adapter.refresh()
pager.adapter = adapter
pager.setCurrentItem(currentItem, false)
}
/**
* Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise.
*/
override fun handleKeyEvent(event: KeyEvent): Boolean {
val isUp = event.action == KeyEvent.ACTION_UP
val ctrlPressed = event.metaState.and(KeyEvent.META_CTRL_ON) > 0
val interval = config.volumeKeysInterval
val movePage = longPressed && (interval == 0 || event.repeatCount % (interval + 1) == 0)
longPressed = event.isLongPress || !isUp && longPressed
when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
if (!config.volumeKeysEnabled || activity.menuVisible) {
return false
} else if (isUp != movePage) {
if (!config.volumeKeysInverted) moveDown() else moveUp()
}
}
KeyEvent.KEYCODE_VOLUME_UP -> {
if (!config.volumeKeysEnabled || activity.menuVisible) {
return false
} else if (isUp != movePage) {
if (!config.volumeKeysInverted) moveUp() else moveDown()
}
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
if (isUp) {
if (ctrlPressed) moveToNext() else moveRight()
}
}
KeyEvent.KEYCODE_DPAD_LEFT -> {
if (isUp) {
if (ctrlPressed) moveToPrevious() else moveLeft()
}
}
KeyEvent.KEYCODE_DPAD_DOWN -> if (isUp) moveDown()
KeyEvent.KEYCODE_DPAD_UP -> if (isUp) moveUp()
KeyEvent.KEYCODE_PAGE_DOWN -> if (isUp) moveDown()
KeyEvent.KEYCODE_PAGE_UP -> if (isUp) moveUp()
KeyEvent.KEYCODE_MENU -> if (isUp) activity.toggleMenu()
else -> return false
}
return true
}
/**
* Called from the containing activity when a generic motion [event] is received. It should
* return true if the event was handled, false otherwise.
*/
override fun handleGenericMotionEvent(event: MotionEvent): Boolean {
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
when (event.action) {
MotionEvent.ACTION_SCROLL -> {
if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0.0f) {
moveDown()
} else {
moveUp()
}
return true
}
}
}
return false
}
}
@@ -1,88 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import eu.kanade.tachiyomi.data.preference.PreferenceValues.TappingInvertMode
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerConfig
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.EdgeNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.KindlishNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
/**
* Configuration used by webtoon viewers.
*/
class WebtoonConfig(
scope: CoroutineScope,
readerPreferences: ReaderPreferences = ReaderPreferences,
) : ViewerConfig(readerPreferences, scope) {
var themeChangedListener: (() -> Unit)? = null
var imageCropBorders = false
private set
var sidePadding = 0
private set
var longStripSplit = false
private set
var longStripSplitChangedListener: ((Boolean) -> Unit)? = null
val theme = readerPreferences.readerTheme().get()
init {
readerPreferences.webtoonSidePadding()
.register({ sidePadding = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.navigationModeWebtoon()
.register({ navigationMode = it }, { updateNavigation(it) })
readerPreferences.webtoonNavInverted().changes().map { TappingInvertMode.entries[it] }
.register({ tappingInverted = it }, { navigator.invertMode = it })
readerPreferences.webtoonNavInverted().changes()
.drop(1)
.onEach { navigationModeChangedListener?.invoke() }
.launchIn(scope)
readerPreferences.dualPageSplitWebtoon()
.register({ dualPageSplit = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.dualPageInvertWebtoon()
.register({ dualPageInvert = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.readerTheme().changes()
.drop(1)
.distinctUntilChanged()
.onEach { themeChangedListener?.invoke() }
.launchIn(scope)
}
override var navigator: ViewerNavigation = defaultNavigation()
set(value) {
field = value.also { it.invertMode = tappingInverted }
}
override fun defaultNavigation(): ViewerNavigation = LNavigation()
override fun updateNavigation(navigationMode: Int) {
this.navigator = when (navigationMode) {
0 -> defaultNavigation()
1 -> LNavigation()
2 -> KindlishNavigation()
3 -> EdgeNavigation()
4 -> RightAndLeftNavigation()
5 -> DisabledNavigation()
else -> defaultNavigation()
}
navigationModeChangedListener?.invoke()
}
}
@@ -1,75 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.content.Context
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.widget.FrameLayout
/**
* Frame layout which contains a [WebtoonRecyclerView]. It's needed to handle touch events,
* because the recyclerview is scaled and its touch events are translated, which breaks the
* detectors.
*
* TODO consider integrating this class into [WebtoonViewer].
*/
class WebtoonFrame(context: Context) : FrameLayout(context) {
/**
* Scale detector, either with pinch or quick scale.
*/
private val scaleDetector = ScaleGestureDetector(context, ScaleListener())
/**
* Fling detector.
*/
private val flingDetector = GestureDetector(context, FlingListener())
/**
* Recycler view added in this frame.
*/
private val recycler: WebtoonRecyclerView?
get() = getChildAt(0) as? WebtoonRecyclerView
/**
* Dispatches a touch event to the detectors.
*/
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
scaleDetector.onTouchEvent(ev)
flingDetector.onTouchEvent(ev)
return super.dispatchTouchEvent(ev)
}
/**
* Scale listener used to delegate events to the recycler view.
*/
inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
recycler?.onScaleBegin()
return true
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
recycler?.onScale(detector.scaleFactor)
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector) {
recycler?.onScaleEnd()
}
}
/**
* Fling listener used to delegate events to the recycler view.
*/
inner class FlingListener : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent): Boolean = true
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float,
): Boolean = recycler?.zoomFling(velocityX.toInt(), velocityY.toInt()) ?: false
}
}
@@ -1,55 +0,0 @@
@file:Suppress("PackageDirectoryMismatch")
package androidx.recyclerview.widget
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
/**
* Layout manager used by the webtoon viewer. Item prefetch is disabled because the extra layout
* space feature is used which allows setting the image even if the holder is not visible,
* avoiding (in most cases) black views when they are visible.
*
* This layout manager uses the same package name as the support library in order to use a package
* protected method.
*/
class WebtoonLayoutManager(activity: ReaderActivity) : LinearLayoutManager(activity) {
/**
* Extra layout space is set to half the screen height.
*/
private val extraLayoutSpace = activity.resources.displayMetrics.heightPixels / 2
init {
isItemPrefetchEnabled = false
}
/**
* Returns the custom extra layout space.
*/
@Deprecated("Deprecated in Java")
override fun getExtraLayoutSpace(state: RecyclerView.State): Int = extraLayoutSpace
/**
* Returns the position of the last item whose end side is visible on screen.
*/
fun findLastEndVisibleItemPosition(): Int {
ensureLayoutState()
val callback = if (mOrientation == HORIZONTAL) {
mHorizontalBoundCheck
} else {
mVerticalBoundCheck
}.mCallback
val start = callback.parentStart
val end = callback.parentEnd
for (i in childCount - 1 downTo 0) {
val child = getChildAt(i)!!
val childStart = callback.getChildStart(child)
val childEnd = callback.getChildEnd(child)
if (childEnd <= end || childStart < start) {
return getPosition(child)
}
}
return NO_POSITION
}
}
@@ -1,335 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.ViewConfiguration
import android.view.animation.DecelerateInterpolator
import androidx.core.animation.doOnEnd
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.viewer.GestureDetectorWithLongTap
import kotlin.math.abs
/**
* Implementation of a [RecyclerView] used by the webtoon reader.
*/
class WebtoonRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
) : RecyclerView(context, attrs, defStyle) {
private var isZooming = false
private var atLastPosition = false
private var atFirstPosition = false
private var halfWidth = 0
private var halfHeight = 0
var originalHeight = 0
private set
private var heightSet = false
private var firstVisibleItemPosition = 0
private var lastVisibleItemPosition = 0
private var currentScale = DEFAULT_RATE
private val listener = GestureListener()
private val detector = Detector()
var tapListener: ((MotionEvent) -> Unit)? = null
var longTapListener: ((MotionEvent) -> Boolean)? = null
init {
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
}
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
halfWidth = MeasureSpec.getSize(widthSpec) / 2
halfHeight = MeasureSpec.getSize(heightSpec) / 2
if (!heightSet) {
originalHeight = MeasureSpec.getSize(heightSpec)
heightSet = true
}
super.onMeasure(widthSpec, heightSpec)
}
override fun onTouchEvent(e: MotionEvent): Boolean {
if (scrollState == SCROLL_STATE_IDLE) {
detector.onTouchEvent(e)
}
return super.onTouchEvent(e)
}
override fun onScrolled(dx: Int, dy: Int) {
super.onScrolled(dx, dy)
val layoutManager = layoutManager
lastVisibleItemPosition =
(layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
}
override fun onScrollStateChanged(state: Int) {
super.onScrollStateChanged(state)
val layoutManager = layoutManager
val visibleItemCount = layoutManager?.childCount ?: 0
val totalItemCount = layoutManager?.itemCount ?: 0
atLastPosition = visibleItemCount > 0 && lastVisibleItemPosition == totalItemCount - 1
atFirstPosition = firstVisibleItemPosition == 0
}
private fun getPositionX(positionX: Float): Float {
if (currentScale < 1) {
return 0f
}
val maxPositionX = halfWidth * (currentScale - 1)
return positionX.coerceIn(-maxPositionX, maxPositionX)
}
private fun getPositionY(positionY: Float): Float {
if (currentScale < 1) {
return (originalHeight / 2 - halfHeight).toFloat()
}
val maxPositionY = halfHeight * (currentScale - 1)
return positionY.coerceIn(-maxPositionY, maxPositionY)
}
private fun zoom(
fromRate: Float,
toRate: Float,
fromX: Float,
toX: Float,
fromY: Float,
toY: Float,
) {
isZooming = true
val animatorSet = AnimatorSet()
val translationXAnimator = ValueAnimator.ofFloat(fromX, toX)
translationXAnimator.addUpdateListener { animation -> x = animation.animatedValue as Float }
val translationYAnimator = ValueAnimator.ofFloat(fromY, toY)
translationYAnimator.addUpdateListener { animation -> y = animation.animatedValue as Float }
val scaleAnimator = ValueAnimator.ofFloat(fromRate, toRate)
scaleAnimator.addUpdateListener { animation ->
setScaleRate(animation.animatedValue as Float)
}
animatorSet.playTogether(translationXAnimator, translationYAnimator, scaleAnimator)
animatorSet.duration = ANIMATOR_DURATION_TIME.toLong()
animatorSet.interpolator = DecelerateInterpolator()
animatorSet.start()
animatorSet.doOnEnd {
isZooming = false
currentScale = toRate
}
}
fun zoomFling(velocityX: Int, velocityY: Int): Boolean {
if (currentScale <= 1f) return false
val distanceTimeFactor = 0.4f
var newX: Float? = null
var newY: Float? = null
if (velocityX != 0) {
val dx = (distanceTimeFactor * velocityX / 2)
newX = getPositionX(x + dx)
}
if (velocityY != 0 && (atFirstPosition || atLastPosition)) {
val dy = (distanceTimeFactor * velocityY / 2)
newY = getPositionY(y + dy)
}
animate()
.apply {
newX?.let { x(it) }
newY?.let { y(it) }
}
.setInterpolator(DecelerateInterpolator())
.setDuration(400)
.start()
return true
}
private fun zoomScrollBy(dx: Int, dy: Int) {
if (dx != 0) {
x = getPositionX(x + dx)
}
if (dy != 0) {
y = getPositionY(y + dy)
}
}
private fun setScaleRate(rate: Float) {
scaleX = rate
scaleY = rate
}
fun onScale(scaleFactor: Float) {
currentScale *= scaleFactor
currentScale = currentScale.coerceIn(
MIN_RATE,
MAX_SCALE_RATE,
)
setScaleRate(currentScale)
layoutParams.height = if (currentScale < 1) {
(originalHeight / currentScale).toInt()
} else {
originalHeight
}
halfHeight = layoutParams.height / 2
if (currentScale != DEFAULT_RATE) {
x = getPositionX(x)
y = getPositionY(y)
} else {
x = 0f
y = 0f
}
requestLayout()
}
fun onScaleBegin() {
if (detector.isDoubleTapping) {
detector.isQuickScaling = true
}
}
fun onScaleEnd() {
if (scaleX < MIN_RATE) {
zoom(currentScale, MIN_RATE, x, 0f, y, 0f)
}
}
inner class GestureListener : GestureDetectorWithLongTap.Listener() {
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
tapListener?.invoke(ev)
return false
}
override fun onDoubleTap(ev: MotionEvent): Boolean {
if (ReaderPreferences.doubleTapToZoom().get()) detector.isDoubleTapping = true
return false
}
fun onDoubleTapConfirmed(ev: MotionEvent) {
if (!isZooming) {
if (scaleX != DEFAULT_RATE) {
zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f)
} else {
val toScale = 2f
val toX = (halfWidth - ev.x) * (toScale - 1)
val toY = (halfHeight - ev.y) * (toScale - 1)
zoom(DEFAULT_RATE, toScale, 0f, toX, 0f, toY)
}
}
}
override fun onLongTapConfirmed(ev: MotionEvent) {
val listener = longTapListener
if (listener != null && listener.invoke(ev)) {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
}
}
inner class Detector : GestureDetectorWithLongTap(context, listener) {
private var scrollPointerId = 0
private var downX = 0
private var downY = 0
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var isZoomDragging = false
var isDoubleTapping = false
var isQuickScaling = false
override fun onTouchEvent(ev: MotionEvent): Boolean {
val action = ev.actionMasked
val actionIndex = ev.actionIndex
when (action) {
MotionEvent.ACTION_DOWN -> {
scrollPointerId = ev.getPointerId(0)
downX = (ev.x + 0.5f).toInt()
downY = (ev.y + 0.5f).toInt()
}
MotionEvent.ACTION_POINTER_DOWN -> {
scrollPointerId = ev.getPointerId(actionIndex)
downX = (ev.getX(actionIndex) + 0.5f).toInt()
downY = (ev.getY(actionIndex) + 0.5f).toInt()
}
MotionEvent.ACTION_MOVE -> {
if (isDoubleTapping && isQuickScaling) {
return true
}
val index = ev.findPointerIndex(scrollPointerId)
if (index < 0) {
return false
}
val x = (ev.getX(index) + 0.5f).toInt()
val y = (ev.getY(index) + 0.5f).toInt()
var dx = x - downX
var dy = if (atFirstPosition || atLastPosition) y - downY else 0
if (!isZoomDragging && currentScale > 1f) {
var startScroll = false
if (abs(dx) > touchSlop) {
if (dx < 0) {
dx += touchSlop
} else {
dx -= touchSlop
}
startScroll = true
}
if (abs(dy) > touchSlop) {
if (dy < 0) {
dy += touchSlop
} else {
dy -= touchSlop
}
startScroll = true
}
if (startScroll) {
isZoomDragging = true
}
}
if (isZoomDragging) {
zoomScrollBy(dx, dy)
}
}
MotionEvent.ACTION_UP -> {
if (isDoubleTapping && !isQuickScaling) {
listener.onDoubleTapConfirmed(ev)
}
isZoomDragging = false
isDoubleTapping = false
isQuickScaling = false
}
MotionEvent.ACTION_CANCEL -> {
isZoomDragging = false
isDoubleTapping = false
isQuickScaling = false
}
}
return super.onTouchEvent(ev)
}
}
}
private const val ANIMATOR_DURATION_TIME = 200
private const val MIN_RATE = 0.5f
private const val DEFAULT_RATE = 1f
private const val MAX_SCALE_RATE = 3f
@@ -1,18 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
/**
* Implementation of subsampling scale image view that ignores all touch events, because the
* webtoon viewer handles all the gestures.
*/
class WebtoonSubsamplingImageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : SubsamplingScaleImageView(context, attrs) {
override fun onTouchEvent(event: MotionEvent): Boolean = false
}
@@ -1,291 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.graphics.PointF
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import androidx.core.app.ActivityCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.WebtoonLayoutManager
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageAdapter
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
/**
* Implementation of a [BaseViewer] to display pages with a [RecyclerView].
*/
class WebtoonViewer(override val activity: ReaderActivity, val isContinuous: Boolean = true) : BaseViewer {
override val isRtl: Boolean = false
private val scope = MainScope()
/**
* Recycler view used by this viewer.
*/
val recycler = WebtoonRecyclerView(activity)
/**
* Frame containing the recycler view.
*/
private val frame = WebtoonFrame(activity)
/**
* Layout manager of the recycler view.
*/
private val layoutManager = WebtoonLayoutManager(activity)
/**
* Configuration used by this viewer, like allow taps, or crop image borders.
*/
val config = WebtoonConfig(scope)
/**
* Adapter of the recycler view.
*/
private val adapter = ReaderPageAdapter(this, true)
/**
* Distance to scroll when the user taps on one side of the recycler view.
*/
private var scrollDistance = activity.resources.displayMetrics.heightPixels * 3 / 4
/**
* Currently active item. It can be a chapter page or a chapter transition.
*/
private var currentPage: Any? = null
private val threshold: Int = 13
private var longPressed = false
init {
recycler.isVisible = false // Don't let the recycler layout yet
recycler.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
recycler.clipToPadding = false
recycler.isFocusable = false
recycler.itemAnimator = null
recycler.layoutManager = layoutManager
recycler.adapter = adapter
recycler.addOnScrollListener(
object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
onScrolled()
if ((dy > threshold || dy < -threshold) && activity.menuVisible) {
activity.hideMenu()
}
}
},
)
recycler.tapListener = { event ->
val windowOffset = IntArray(2)
activity.window.decorView.getLocationOnScreen(windowOffset)
val pos = PointF(
(event.rawX - windowOffset[0]) / recycler.width,
(event.rawY - windowOffset[1]) / recycler.originalHeight,
)
val navigator = config.navigator
when (navigator.getAction(pos)) {
NavigationRegion.MENU -> activity.toggleMenu()
NavigationRegion.NEXT, NavigationRegion.RIGHT -> scrollDown()
NavigationRegion.PREV, NavigationRegion.LEFT -> scrollUp()
}
}
recycler.longTapListener = f@{ event ->
if (activity.menuVisible || config.longTapEnabled) {
val child = recycler.findChildViewUnder(event.x, event.y)
if (child != null) {
val position = recycler.getChildAdapterPosition(child)
val item = adapter.items.getOrNull(position)
if (item is ReaderPage) {
activity.onPageLongTap(item)
return@f true
}
}
}
false
}
config.imagePropertyChangedListener = {
refreshAdapter()
}
config.themeChangedListener = {
ActivityCompat.recreate(activity)
}
config.navigationModeChangedListener = {
val showOnStart = config.navigationOverlayOnStart || config.forceNavigationOverlay
activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart)
}
frame.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
frame.addView(recycler)
}
override val readerConfig
get() = ReaderPageImageView.Config(
zoomDuration = config.doubleTapAnimDuration,
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_FIT_WIDTH,
cropBorders = config.imageCropBorders,
)
override fun getViewForInsets(): View = recycler
/**
* Returns the view this viewer uses.
*/
override fun getView(): View = frame
/**
* Destroys this viewer. Called when leaving the reader or swapping viewers.
*/
override fun destroy() {
scope.cancel()
}
/**
* Called from the RecyclerView listener when a [page] is marked as active. It notifies the
* activity of the change and requests the preload of the next chapter if this is the last page.
*/
private fun onPageSelected(page: ReaderPage) {
activity.onPageSelected(page)
}
/**
* Tells this viewer to set the given [provider] as active.
*/
override fun setGalleryProvider(provider: PageLoader) {
adapter.setChapters(provider)
refreshAdapter(0)
if (recycler.isGone) {
logcat { "Recycler first layout" }
recycler.isVisible = true
}
}
/**
* Tells this viewer to move to the given [page].
*/
override fun moveToPage(page: ReaderPage) {
logcat { "moveToPage" }
val position = adapter.items.indexOf(page)
if (position != -1) {
layoutManager.scrollToPositionWithOffset(position, 0)
onScrolled(pos = position)
} else {
logcat { "Page $page not found in adapter" }
}
}
fun onScrolled(pos: Int? = null) {
val position = pos ?: layoutManager.findLastEndVisibleItemPosition()
val item = adapter.items.getOrNull(position)
if (item != null && currentPage != item) {
currentPage = item
onPageSelected(item)
}
}
/**
* Scrolls up by [scrollDistance].
*/
private fun scrollUp() {
if (config.usePageTransitions) {
recycler.smoothScrollBy(0, -scrollDistance)
} else {
recycler.scrollBy(0, -scrollDistance)
}
}
/**
* Scrolls down by [scrollDistance].
*/
private fun scrollDown() {
if (config.usePageTransitions) {
recycler.smoothScrollBy(0, scrollDistance)
} else {
recycler.scrollBy(0, scrollDistance)
}
}
/**
* Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise.
*/
override fun handleKeyEvent(event: KeyEvent): Boolean {
val isUp = event.action == KeyEvent.ACTION_UP
val interval = config.volumeKeysInterval
val movePage = longPressed && (interval == 0 || event.repeatCount % (interval + 1) == 0)
longPressed = event.isLongPress || !isUp && longPressed
when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
if (!config.volumeKeysEnabled || activity.menuVisible) {
return false
} else if (isUp != movePage) {
if (!config.volumeKeysInverted) scrollDown() else scrollUp()
}
}
KeyEvent.KEYCODE_VOLUME_UP -> {
if (!config.volumeKeysEnabled || activity.menuVisible) {
return false
} else if (isUp != movePage) {
if (!config.volumeKeysInverted) scrollUp() else scrollDown()
}
}
KeyEvent.KEYCODE_MENU -> if (isUp) activity.toggleMenu()
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_PAGE_UP,
-> if (isUp) scrollUp()
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_PAGE_DOWN,
-> if (isUp) scrollDown()
else -> return false
}
return true
}
/**
* Called from the containing activity when a generic motion [event] is received. It should
* return true if the event was handled, false otherwise.
*/
override fun handleGenericMotionEvent(event: MotionEvent): Boolean = false
override fun refreshAdapter() {
refreshAdapter(layoutManager.findLastEndVisibleItemPosition())
}
/**
* Notifies adapter of changes around the current page to trigger a relayout in the recycler.
* Used when an image configuration is changed.
*/
private fun refreshAdapter(position: Int) {
val positionStart = maxOf(0, position - 3)
val positionEnd = minOf(position + 3, adapter.itemCount - 1)
adapter.refresh()
adapter.notifyItemRangeChanged(positionStart, positionEnd - positionStart + 1)
}
}
@@ -1,9 +1,9 @@
package eu.kanade.tachiyomi.util.lang
import android.graphics.RectF
import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.ui.reader.setting.TappingInvertMode
fun RectF.invert(invertMode: PreferenceValues.TappingInvertMode): RectF {
fun RectF.invert(invertMode: TappingInvertMode): RectF {
val horizontal = invertMode.shouldInvertHorizontal
val vertical = invertMode.shouldInvertVertical
return when {
@@ -1,55 +0,0 @@
package eu.kanade.tachiyomi.util.system
import android.content.Context
import android.content.res.Configuration
import android.content.res.Resources
import android.provider.Settings
import androidx.appcompat.view.ContextThemeWrapper
import com.hippo.ehviewer.R
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
/**
* Converts to px.
*/
val Int.dpToPx: Int
get() = (this * Resources.getSystem().displayMetrics.density).toInt()
/** Gets the duration multiplier for general animations on the device
* @see Settings.Global.ANIMATOR_DURATION_SCALE
*/
val Context.animatorDurationScale: Float
get() = Settings.Global.getFloat(this.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f)
private const val TABLET_UI_REQUIRED_SCREEN_WIDTH_DP = 720
fun Configuration.isTabletUi(): Boolean = smallestScreenWidthDp >= TABLET_UI_REQUIRED_SCREEN_WIDTH_DP
/**
* Returns true if current context is in night mode
*/
fun Context.isNightMode(): Boolean = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
/**
* Creates night mode Context depending on reader theme/background
*
* Context wrapping method obtained from AppCompatDelegateImpl
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:appcompat/appcompat/src/main/java/androidx/appcompat/app/AppCompatDelegateImpl.java;l=348;drc=e28752c96fc3fb4d3354781469a1af3dbded4898
*/
fun Context.createReaderThemeContext(): Context {
val isDarkBackground = when (ReaderPreferences.readerTheme().get()) {
1, 2 -> true // Black, Gray
3 -> applicationContext.isNightMode() // Automatic bg uses activity background by default
else -> false // White
}
val expected = if (isDarkBackground) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
if (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK != expected) {
val overrideConf = Configuration()
overrideConf.setTo(resources.configuration)
overrideConf.uiMode = overrideConf.uiMode and Configuration.UI_MODE_NIGHT_MASK.inv() or expected
val wrappedContext = ContextThemeWrapper(this, R.style.AppTheme)
wrappedContext.applyOverrideConfiguration(overrideConf)
return wrappedContext
}
return this
}
@@ -1,18 +0,0 @@
package eu.kanade.tachiyomi.util.view
import android.content.res.Resources
import android.graphics.Rect
import android.view.View
fun View?.isVisibleOnScreen(): Boolean {
if (this == null) {
return false
}
if (!this.isShown) {
return false
}
val actualPosition = Rect()
this.getGlobalVisibleRect(actualPosition)
val screen = Rect(0, 0, Resources.getSystem().displayMetrics.widthPixels, Resources.getSystem().displayMetrics.heightPixels)
return actualPosition.intersect(screen)
}
@@ -1,38 +0,0 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/reader_container"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/viewer_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:descendantFocusability="blocksDescendants" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/page_number"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:padding="4dp" />
</FrameLayout>
<eu.kanade.tachiyomi.ui.reader.ReaderNavigationOverlayView
android:id="@+id/navigation_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="false"
android:focusable="false"
android:visibility="gone" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/dialog_stub"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
-2
View File
@@ -93,8 +93,6 @@
- [libarchive](https://www.libarchive.org/)
- [jsoup](https://jsoup.org/)
特别感谢[Tachiyomi](https://tachiyomi.org/)的阅读器实现
# 许可证
Copyright 2014-2019 Hippo Seven
-2
View File
@@ -93,8 +93,6 @@
- [libarchive](https://www.libarchive.org/)
- [jsoup](https://jsoup.org/)
特別感謝[Tachiyomi](https://tachiyomi.org/)的閱讀器實現
# 許可證書
Copyright 2014-2019 Hippo Seven
-9
View File
@@ -13,9 +13,7 @@ androidx-datastore = "1.1.1"
androidx-graphics-path = "1.0.1"
androidx-lifecycle = "2.8.4"
androidx-paging = "3.3.1"
androidx-recyclerview = "1.4.0-alpha01"
androidx-room = "2.7.0-alpha05"
androidx-viewpager2 = "1.1.0"
androidx-work = "2.10.0-alpha02"
arrow = "1.2.4"
coil = "3.0.0-alpha09"
@@ -55,13 +53,9 @@ androidx-lifecycle-compose = { module = "androidx.lifecycle:lifecycle-runtime-co
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" }
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "androidx-recyclerview" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" }
androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "androidx-room" }
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "androidx-viewpager2" }
androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "androidx-work" }
arrow-fx-coroutines = { module = "io.arrow-kt:arrow-fx-coroutines" }
@@ -92,7 +86,6 @@ cronet-embedded = "org.chromium.net:cronet-embedded:119.6045.31"
desugar = "com.android.tools:desugar_jdk_libs_nio:2.0.4"
diff = "io.github.petertrr:kotlin-multiplatform-diff:0.7.0"
insetter = "dev.chrisbanes.insetter:insetter:0.6.1"
jsoup = "org.jsoup:jsoup:1.18.1"
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
@@ -106,8 +99,6 @@ logcat = "com.squareup.logcat:logcat:0.1"
okio-jvm = { module = "com.squareup.okio:okio-jvm", version.ref = "okio" }
photoview = "io.getstream:photoview:1.0.2"
reorderable = "sh.calvin.reorderable:reorderable:2.2.0"
serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinx-serialization" }