Graduate Compose Reader to stable
This commit is contained in:
1 parent
01a1113a07
commit
1ecdc07655
49 files changed
+29
-5631
No files matched your search
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
-1640
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
}
|
||||
-75
@@ -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
|
||||
}
|
||||
}
|
||||
-55
@@ -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
|
||||
}
|
||||
}
|
||||
-335
@@ -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
|
||||
-18
@@ -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>
|
||||
@@ -93,8 +93,6 @@
|
||||
- [libarchive](https://www.libarchive.org/)
|
||||
- [jsoup](https://jsoup.org/)
|
||||
|
||||
特别感谢[Tachiyomi](https://tachiyomi.org/)的阅读器实现
|
||||
|
||||
# 许可证
|
||||
|
||||
Copyright 2014-2019 Hippo Seven
|
||||
|
||||
@@ -93,8 +93,6 @@
|
||||
- [libarchive](https://www.libarchive.org/)
|
||||
- [jsoup](https://jsoup.org/)
|
||||
|
||||
特別感謝[Tachiyomi](https://tachiyomi.org/)的閱讀器實現
|
||||
|
||||
# 許可證書
|
||||
|
||||
Copyright 2014-2019 Hippo Seven
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in new issue
Block a user