Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6191ad74d3 | ||
|
|
5aae8f5649 | ||
|
|
6886465afb | ||
|
|
5a46fc4573 | ||
|
|
a08b8e96e5 | ||
|
|
5aeded7a24 | ||
|
|
1883b82159 | ||
|
|
302b57d4ac | ||
|
|
c6b793923e | ||
|
|
058c280fd7 | ||
|
|
0fa42d731b | ||
|
|
9b54b57934 | ||
|
|
d3ab3ba61f | ||
|
|
921f570151 | ||
|
|
03ab435401 | ||
|
|
b73704ede1 | ||
|
|
97b2181ff7 | ||
|
|
ef5e3f7c51 | ||
|
|
8a29d46b38 | ||
|
|
75d8ff4b64 | ||
|
|
33258ea554 | ||
|
|
a723b4ecd3 | ||
|
|
78a237fb0e | ||
|
|
8fe60c0163 | ||
|
|
ffe47b2ff2 | ||
|
|
e05fffb41c | ||
|
|
1fb5de2527 | ||
|
|
46790a1fbe | ||
|
|
af1316f06b | ||
|
|
24564cac8d | ||
|
|
c4868f137b | ||
|
|
3ccfa0dd5b | ||
|
|
fded0fd08d | ||
|
|
b1afe9bcd1 | ||
|
|
22b5a175f4 | ||
|
|
ee9e0df12e | ||
|
|
dd525baf88 | ||
|
|
9180603ead | ||
|
|
66fe19369e | ||
|
|
b9db025f57 | ||
|
|
44f6eca3aa | ||
|
|
1ff1caf421 | ||
|
|
ec1355c0e7 | ||
|
|
d23a453f71 | ||
|
|
94b138f254 | ||
|
|
c281daa4ea | ||
|
|
4f9eba8c63 | ||
|
|
a2547f1e8d | ||
|
|
abcef9a86e | ||
|
|
7d6a9b10e4 | ||
|
|
9322b80790 | ||
|
|
e12897bd13 | ||
|
|
28279cc671 | ||
|
|
2531fa289b | ||
|
|
9673163e8f | ||
|
|
3f7091f880 | ||
|
|
5df75fd534 | ||
|
|
62061e54ee | ||
|
|
436c07fb6b | ||
|
|
a16f873f7d | ||
|
|
98fdcf0588 | ||
|
|
539ad1af30 | ||
|
|
4370606977 | ||
|
|
1ecdc07655 | ||
|
|
01a1113a07 | ||
|
|
fd6c2977b0 | ||
|
|
02e36e7021 | ||
|
|
7f8e4aa003 | ||
|
|
8ce16bc918 | ||
|
|
fbe35a0fa0 | ||
|
|
931f1ec514 | ||
|
|
7c4a59e9a4 | ||
|
|
c1c6f25763 | ||
|
|
e7f515c70f | ||
|
|
64fecbfa52 | ||
|
|
c6c181a9fa | ||
|
|
53a1bd9959 | ||
|
|
7b1f94cf5a | ||
|
|
4b8ecbf6a7 | ||
|
|
0e0b0519e7 | ||
|
|
7fb3b7c785 | ||
|
|
e18ebb1feb | ||
|
|
f76dcfc97b | ||
|
|
c0c630bd9f | ||
|
|
04df7da5dc | ||
|
|
d156638a79 | ||
|
|
c78fe0b72c | ||
|
|
799c37ea71 | ||
|
|
9559ca571b | ||
|
|
2f5c1e11c6 | ||
|
|
734c73fa4d | ||
|
|
c09173d62c | ||
|
|
533f50ed08 | ||
|
|
b4422fe910 | ||
|
|
22e58a034f | ||
|
|
784856d341 | ||
|
|
f82832ca36 | ||
|
|
6a70aa792e | ||
|
|
a0016dbf85 | ||
|
|
b982705b2f | ||
|
|
4c36398bed |
No files matched your search
@@ -1,84 +0,0 @@
|
||||
name: Baseline profile generation
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 0 * * 0'
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
paths:
|
||||
- 'benchmark/**'
|
||||
- '.github/workflows/baseline-profile.yml'
|
||||
|
||||
jobs:
|
||||
baseline-profile:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Free disk space
|
||||
run: rm -rf ${ANDROID_SDK_ROOT}/ndk
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./app/src/main/rust"
|
||||
shared-key: ${{ runner.os }}
|
||||
save-if: "false"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: CMake Cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: app/.cxx
|
||||
key: cmake-
|
||||
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew generateBaselineProfile
|
||||
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
|
||||
|
||||
- name: Update baseline profile
|
||||
if: github.ref == 'refs/heads/main'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config push.autoSetupRemote true
|
||||
git switch -c chore/baseline-profile
|
||||
if git commit -am "Update baseline profile"; then
|
||||
git push
|
||||
gh pr create -f
|
||||
fi
|
||||
|
||||
- name: Upload reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: reports
|
||||
path: benchmark/build/reports
|
||||
@@ -38,13 +38,10 @@ jobs:
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-read-only: true
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
|
||||
|
||||
- name: Spotless Check
|
||||
run: ./gradlew spotlessCheck
|
||||
|
||||
@@ -63,9 +60,6 @@ jobs:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
@@ -77,10 +71,9 @@ jobs:
|
||||
save-if: "false"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
|
||||
gradle-home-cache-cleanup: true
|
||||
|
||||
- name: CMake Cache
|
||||
uses: actions/cache@v4
|
||||
@@ -90,8 +83,6 @@ jobs:
|
||||
restore-keys: cmake-default-
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GithubToken }}
|
||||
run: ./gradlew assembleDefaultRelease
|
||||
|
||||
- name: Upload ARM64
|
||||
@@ -136,9 +127,6 @@ jobs:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
@@ -150,10 +138,9 @@ jobs:
|
||||
save-if: "false"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
|
||||
gradle-home-cache-cleanup: true
|
||||
|
||||
- name: CMake Cache
|
||||
uses: actions/cache@v4
|
||||
@@ -163,8 +150,6 @@ jobs:
|
||||
restore-keys: cmake-marshmallow-
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GithubToken }}
|
||||
run: ./gradlew assembleMarshmallowRelease
|
||||
|
||||
- name: Upload ARM64
|
||||
|
||||
@@ -18,8 +18,16 @@ jobs:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
|
||||
- name: Free disk space
|
||||
run: |
|
||||
shopt -s extglob
|
||||
rm -rf ${ANDROID_SDK_ROOT}/ndk/!(27*)
|
||||
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
|
||||
@@ -36,12 +44,17 @@ jobs:
|
||||
key: cmake-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
|
||||
restore-keys: cmake-
|
||||
|
||||
- name: Gradle Build
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GithubToken }}
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
arguments: assembleRelease
|
||||
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
|
||||
|
||||
- name: Generate Baseline Profile
|
||||
run: ./gradlew generateBaselineProfile
|
||||
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Rename Apks
|
||||
run: |
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# 变更日志
|
||||
|
||||
## [1.12.0] - 2024-08-19
|
||||
|
||||
### 重大变更
|
||||
|
||||
* Compose 阅读器已稳定,移除了旧阅读器实现 [#847](https://github.com/FooIbar/EhViewer/issues/847)
|
||||
* 由于 E 站缩略图服务器变更,移除了强制使用 e-hentai 缩略图服务器的选项
|
||||
|
||||
### 改进
|
||||
|
||||
* 更新高级搜索选项中页数过滤器的范围限制以遵循 E 站变更
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 评论页面刷新指示器边距不正确 [#1428](https://github.com/FooIbar/EhViewer/issues/1428)
|
||||
* 关闭打开失败的压缩包时崩溃
|
||||
* 无法打开带密码的压缩包 [#1454](https://github.com/FooIbar/EhViewer/issues/1454)
|
||||
* 使用音量键翻页时无法关闭过渡动画 [#1467](https://github.com/FooIbar/EhViewer/issues/1467)
|
||||
* 带有图片的评论显示错位 [#1468](https://github.com/FooIbar/EhViewer/issues/1468)
|
||||
* 在包含相同项目的页面间切换时崩溃 [#1490](https://github.com/FooIbar/EhViewer/issues/1490)
|
||||
* 无法检查更新 [#1512](https://github.com/FooIbar/EhViewer/issues/1512)
|
||||
* 画廊详情未加载完成时退出阅读器导致崩溃 [#1507](https://github.com/FooIbar/EhViewer/issues/1507)
|
||||
* 无法为带有临时标签的画廊生成 ComicInfo.xml [#1509](https://github.com/FooIbar/EhViewer/issues/1509)
|
||||
* 已下载的画廊翻页时出现加载动画 [#1510](https://github.com/FooIbar/EhViewer/issues/1510)
|
||||
* 画廊详情页面的缩略图无法加载 [#1529](https://github.com/FooIbar/EhViewer/issues/1529)
|
||||
* 图片未加载完成时无法通过点击翻页
|
||||
* 搜索结果为空时解析失败 [#1418](https://github.com/FooIbar/EhViewer/issues/1418)
|
||||
* 缩略图分辨率设置不起作用
|
||||
|
||||
|
||||
## [1.11.7] - 2024-07-10
|
||||
|
||||
### Bug 修复
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
-19
@@ -1,6 +1,6 @@
|
||||
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP
|
||||
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag
|
||||
|
||||
val isRelease: Boolean
|
||||
get() = gradle.startParameter.taskNames.any { it.contains("Release") }
|
||||
@@ -21,9 +21,9 @@ plugins {
|
||||
val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
|
||||
|
||||
android {
|
||||
compileSdk = if (isRelease) 35 else 34
|
||||
compileSdk = 35
|
||||
buildToolsVersion = "35.0.0"
|
||||
ndkVersion = "27.0.11902837-rc1"
|
||||
ndkVersion = "27.0.12077973"
|
||||
androidResources.generateLocaleConfig = true
|
||||
|
||||
splits {
|
||||
@@ -64,15 +64,12 @@ android {
|
||||
val chromeVersion = rootProject.layout.projectDirectory.file("chrome-for-testing/LATEST_RELEASE_STABLE").asFile
|
||||
.readText().substringBefore('.')
|
||||
|
||||
val githubToken = gradleLocalProperties(rootDir, providers)["GITHUB_TOKEN"] as? String
|
||||
?: System.getenv("GITHUB_TOKEN").orEmpty()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "moe.tarsin.ehviewer"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 180058
|
||||
versionName = "1.11.7"
|
||||
versionCode = 180059
|
||||
versionName = "1.12.0"
|
||||
// versionNameSuffix = "-SNAPSHOT"
|
||||
resourceConfigurations.addAll(
|
||||
listOf(
|
||||
@@ -95,7 +92,6 @@ android {
|
||||
buildConfigField("long", "COMMIT_TIME", commitTime)
|
||||
buildConfigField("String", "REPO_NAME", "\"$repoName\"")
|
||||
buildConfigField("String", "CHROME_VERSION", "\"$chromeVersion\"")
|
||||
buildConfigField("String", "GITHUB_TOKEN", "\"$githubToken\"")
|
||||
ndk {
|
||||
if (isRelease) {
|
||||
abiFilters.addAll(supportedAbis)
|
||||
@@ -169,15 +165,13 @@ android {
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
compose = true
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
namespace = "com.hippo.ehviewer"
|
||||
}
|
||||
|
||||
composeCompiler {
|
||||
enableNonSkippingGroupOptimization = true
|
||||
enableStrongSkippingMode = true
|
||||
featureFlags = setOf(ComposeFeatureFlag.OptimizeNonSkippingGroups)
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
@@ -223,15 +217,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,9 +236,7 @@ dependencies {
|
||||
implementation(libs.aboutlibraries.compose.m3)
|
||||
implementation(libs.accompanist.drawable.painter)
|
||||
|
||||
implementation(libs.insetter) // Dead Dependency
|
||||
|
||||
// implementation(libs.reorderable)
|
||||
implementation(libs.reorderable)
|
||||
|
||||
implementation(platform(libs.arrow.stack))
|
||||
implementation(libs.arrow.fx.coroutines)
|
||||
@@ -291,10 +279,12 @@ kotlin {
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
|
||||
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
|
||||
"-opt-in=androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
|
||||
"-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
|
||||
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
|
||||
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
|
||||
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
|
||||
"-opt-in=androidx.compose.animation.ExperimentalSharedTransitionApi",
|
||||
"-opt-in=androidx.paging.ExperimentalPagingApi",
|
||||
"-opt-in=kotlin.contracts.ExperimentalContracts",
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
|
||||
Vendored
-5
@@ -11,11 +11,6 @@
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
|
||||
# https://issuetracker.google.com/347963245
|
||||
-keep class * extends androidx.room.RoomDatabase {
|
||||
<init>();
|
||||
}
|
||||
|
||||
# Ktor logger
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
|
||||
|
||||
@@ -70,26 +70,6 @@
|
||||
<data android:host="exhentai.org" />
|
||||
<data android:host="e-hentai.org" />
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/keyword_search">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/image_search">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="eu.kanade.tachiyomi.ui.reader.ReaderActivity"
|
||||
android:configChanges="screenSize"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -123,6 +103,20 @@
|
||||
<data android:mimeType="application/x-lzma-compressed-tar" />
|
||||
<data android:mimeType="application/x-xz-compressed-tar" />
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/keyword_search">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/image_search">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.SecurityActivity"
|
||||
|
||||
+38
-32
@@ -52,7 +52,7 @@ pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static archive_ctx **ctx_pool = NULL;
|
||||
#define CTX_POOL_SIZE 20
|
||||
|
||||
static void *mempool = NULL;
|
||||
static void *mempool = MAP_FAILED;
|
||||
static size_t *mempoolofs = NULL;
|
||||
static int page_size = 0;
|
||||
|
||||
@@ -62,11 +62,11 @@ static int page_size = 0;
|
||||
#define MEMPOOL_SIZE (mempoolofs[entryCount - 1])
|
||||
|
||||
#define PROT_RW (PROT_WRITE | PROT_READ)
|
||||
#define MAP_ANON_POOL (MAP_ANONYMOUS | MAP_NORESERVE | MAP_PRIVATE | MAP_UNINITIALIZED)
|
||||
#define MAP_ANON_POOL (MAP_ANONYMOUS | MAP_NORESERVE | MAP_PRIVATE)
|
||||
|
||||
static bool need_encrypt = false;
|
||||
static char *passwd = NULL;
|
||||
static void *archiveAddr = NULL;
|
||||
static void *archiveAddr = MAP_FAILED;
|
||||
static size_t archiveSize = 0;
|
||||
static entry *entries = NULL;
|
||||
static size_t entryCount = 0;
|
||||
@@ -117,8 +117,8 @@ static inline int compare_entries(const void *a, const void *b) {
|
||||
return strnatcmp(fa, fb);
|
||||
}
|
||||
|
||||
static long archive_map_entries_index(archive_ctx *ctx, bool sort) {
|
||||
long count = 0;
|
||||
static int archive_map_entries_index(archive_ctx *ctx, bool sort) {
|
||||
int count = 0;
|
||||
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) {
|
||||
const char *name = archive_entry_pathname2(ctx->entry);
|
||||
if (archive_entry_is_file(ctx->entry) && filename_is_playable_file(name)) {
|
||||
@@ -165,8 +165,8 @@ static void mempool_prefault_pages(void *addr, size_t size) {
|
||||
}
|
||||
}
|
||||
|
||||
static long archive_list_all_entries(archive_ctx *ctx) {
|
||||
long count = 0;
|
||||
static int archive_list_all_entries(archive_ctx *ctx) {
|
||||
int count = 0;
|
||||
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK)
|
||||
if (archive_entry_is_playable(ctx->entry))
|
||||
count++;
|
||||
@@ -296,11 +296,12 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint
|
||||
LOGE("Allocate archive ctx pool failed:ENOMEM");
|
||||
return 0;
|
||||
}
|
||||
long r = archive_alloc_ctx(&ctx);
|
||||
int r = archive_alloc_ctx(&ctx);
|
||||
if (r) {
|
||||
if (r == -ENOMEM)
|
||||
LOGE("%s", "Mem alloc failed");
|
||||
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
|
||||
return 0;
|
||||
} else {
|
||||
entryCount = archive_list_all_entries(ctx);
|
||||
LOGI("%s%zu%s", "Found ", entryCount, " image entries in archive");
|
||||
@@ -347,10 +348,6 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint
|
||||
r = archive_map_entries_index(ctx, sort_entries);
|
||||
if (!archive_prealloc_mempool()) {
|
||||
r = 0;
|
||||
for (int i = 0; i < entryCount; ++i) {
|
||||
free((void *) entries[i].filename);
|
||||
}
|
||||
free(entries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,20 +400,32 @@ JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
if (!ctx_pool) return;
|
||||
for (int i = 0; i < CTX_POOL_SIZE; i++)
|
||||
archive_release_ctx(ctx_pool[i]);
|
||||
if (ctx_pool) {
|
||||
for (int i = 0; i < CTX_POOL_SIZE; i++)
|
||||
archive_release_ctx(ctx_pool[i]);
|
||||
free(ctx_pool);
|
||||
ctx_pool = NULL;
|
||||
}
|
||||
free(passwd);
|
||||
free(ctx_pool);
|
||||
passwd = NULL;
|
||||
need_encrypt = false;
|
||||
munmap(archiveAddr, archiveSize);
|
||||
munmap(mempool, MEMPOOL_SIZE);
|
||||
if (archiveAddr != MAP_FAILED) {
|
||||
munmap(archiveAddr, archiveSize);
|
||||
archiveAddr = MAP_FAILED;
|
||||
}
|
||||
if (mempool != MAP_FAILED) {
|
||||
munmap(mempool, MEMPOOL_SIZE);
|
||||
mempool = MAP_FAILED;
|
||||
}
|
||||
free(mempoolofs);
|
||||
for (int i = 0; i < entryCount; ++i) {
|
||||
free((void *) entries[i].filename);
|
||||
mempoolofs = NULL;
|
||||
if (entries) {
|
||||
for (int i = 0; i < entryCount; ++i) {
|
||||
free((void *) entries[i].filename);
|
||||
}
|
||||
free(entries);
|
||||
entries = NULL;
|
||||
}
|
||||
free(entries);
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
@@ -433,12 +442,9 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
|
||||
archive_ctx *ctx;
|
||||
jboolean ret = true;
|
||||
int len = (*env)->GetStringUTFLength(env, str);
|
||||
passwd = realloc(passwd, len * sizeof(char));
|
||||
if (!passwd) {
|
||||
LOGE("Allocate passwd buffer failed");
|
||||
return false;
|
||||
}
|
||||
passwd = realloc(passwd, len + 1);
|
||||
(*env)->GetStringUTFRegion(env, str, 0, len, passwd);
|
||||
passwd[len] = 0;
|
||||
archive_alloc_ctx(&ctx);
|
||||
void *tmpBuf = alloca(4096);
|
||||
while (archive_read_next_header(ctx->arc, &entry) == ARCHIVE_OK) {
|
||||
@@ -487,7 +493,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_releaseByteBuffer(JNIEnv *env, jclass thiz
|
||||
if (!ADDR_IN_FILE_MAPPING(addr)) mempool_release_pages(addr, size);
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_archiveFdBatch(JNIEnv *env, jclass clazz, jintArray fd_batch, jobjectArray names, jint arc_fd, jint size) {
|
||||
EH_UNUSED(clazz);
|
||||
struct archive *arc = archive_write_new();
|
||||
@@ -509,15 +515,15 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_archiveFdBatch(JNIEnv *env, jclass clazz,
|
||||
archive_entry_copy_stat(entry, &st);
|
||||
archive_entry_set_perm(entry, 0644);
|
||||
archive_write_header(arc, entry);
|
||||
int len = read(fd, buff, sizeof(buff));
|
||||
while (len > 0) {
|
||||
archive_write_data(arc, buff, len);
|
||||
int len;
|
||||
do {
|
||||
len = read(fd, buff, sizeof(buff));
|
||||
}
|
||||
archive_write_data(arc, buff, len);
|
||||
} while (len > 0);
|
||||
archive_write_finish_entry(arc);
|
||||
archive_entry_clear(entry);
|
||||
}
|
||||
archive_entry_free(entry);
|
||||
archive_write_close(arc);
|
||||
archive_write_free(arc);
|
||||
return true;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -365,25 +365,23 @@ internal class LazyGridScrollbarAdapter(
|
||||
}
|
||||
|
||||
override fun averageVisibleLineSize(): Double {
|
||||
val visibleItemsInfo = scrollState.layoutInfo.visibleItemsInfo
|
||||
val indexOfFirstKnownLineItem = visibleItemsInfo.indexOfFirst { it.line() != unknownLine }
|
||||
if (indexOfFirstKnownLineItem == -1) {
|
||||
val visibleItemsInfo = // Non-exiting visible items
|
||||
scrollState.layoutInfo.visibleItemsInfo.filter { it.line() != unknownLine }
|
||||
if (visibleItemsInfo.isEmpty()) {
|
||||
return 0.0
|
||||
}
|
||||
val reallyVisibleItemsInfo = // Non-exiting visible items
|
||||
visibleItemsInfo.subList(indexOfFirstKnownLineItem, visibleItemsInfo.size)
|
||||
|
||||
// Compute the size of the last line
|
||||
val lastLine = reallyVisibleItemsInfo.last().line()
|
||||
val lastLineSize = reallyVisibleItemsInfo
|
||||
val last = visibleItemsInfo.last()
|
||||
val lastLine = last.line()
|
||||
val lastLineSize = visibleItemsInfo
|
||||
.asReversed()
|
||||
.asSequence()
|
||||
.takeWhile { it.line() == lastLine }
|
||||
.maxOf { it.mainAxisSize() }
|
||||
|
||||
val first = reallyVisibleItemsInfo.first()
|
||||
val last = reallyVisibleItemsInfo.last()
|
||||
val lineCount = last.line() - first.line() + 1
|
||||
val first = visibleItemsInfo.first()
|
||||
val lineCount = lastLine - first.line() + 1
|
||||
val lineSpacingSum = (lineCount - 1) * lineSpacing
|
||||
return (
|
||||
last.mainAxisOffset() + lastLineSize - first.mainAxisOffset() - lineSpacingSum
|
||||
|
||||
@@ -100,5 +100,4 @@ fun SearchBarInputField(
|
||||
val SearchBarIconOffsetX = 4.dp
|
||||
val SearchBarMinWidth = 360.dp
|
||||
val SearchBarMaxWidth = 720.dp
|
||||
val SearchBarHorizontalPadding = 16.dp
|
||||
const val AnimationDelayMillis = 100
|
||||
-1640
File diff suppressed because it is too large.
Load diff
@@ -30,7 +30,7 @@ import coil3.SingletonImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.network.ktor.KtorNetworkFetcherFactory
|
||||
import coil3.network.ktor2.KtorNetworkFetcherFactory
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
|
||||
@@ -142,7 +142,6 @@ object Settings : DataStorePreferences(null) {
|
||||
var launchPage by intPref("launch_page_2", 0)
|
||||
var commentThreshold by intPref("comment_threshold", -101)
|
||||
var hardwareBitmapThreshold by intPref("hardware_bitmap_threshold", 16384)
|
||||
var forceEhThumb by boolPref("force_eh_thumb", false)
|
||||
var showComments by boolPref("show_gallery_comments", true)
|
||||
var requestNews by boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
|
||||
var hideHvEvents by boolPref("hide_hv_events", false)
|
||||
@@ -175,9 +174,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)
|
||||
@@ -209,6 +206,8 @@ object Settings : DataStorePreferences(null) {
|
||||
val imageScaleType = intPref("pref_image_scale_type_key", 1)
|
||||
val landscapeZoom = boolPref("landscape_zoom", false)
|
||||
val zoomStart = intPref("pref_zoom_start_key", 1)
|
||||
val showNavigationOverlayNewUser = boolPref("reader_navigation_overlay_new_user", true)
|
||||
val showNavigationOverlayOnStart = boolPref("reader_navigation_overlay_on_start", false)
|
||||
|
||||
init {
|
||||
if ("CN" == Locale.getDefault().country) {
|
||||
|
||||
@@ -15,32 +15,35 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.NormalGalleryPreview
|
||||
|
||||
// Normal Preview: https://*.hath.network/cm/[timed token]/[gid]-[index].jpg
|
||||
// ExHentai Large Preview: https://s.exhentai.org/t/***
|
||||
// ExHentai Normal Preview: https://s.exhentai.org/m/***
|
||||
// EHentai Normal Preview: https://ehgt.org/m/***
|
||||
// EHentai Large Preview: https://ehgt.org/***
|
||||
// E-Hentai Large Preview: https://ehgt.org/***
|
||||
|
||||
private const val URL_PREFIX_THUMB_E = "https://ehgt.org/"
|
||||
private const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/"
|
||||
private const val URL_PREFIX_LARGE_THUMB_EX = URL_PREFIX_THUMB_EX + "t/"
|
||||
private const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/t/"
|
||||
private const val NORMAL_PREVIEW_PREFIX = "$"
|
||||
private val NormalPreviewKeyRegex = Regex("/(\\d+-\\d+)\\.jpg$")
|
||||
|
||||
fun getImageKey(gid: Long, index: Int): String = "image:$gid:$index"
|
||||
|
||||
fun getThumbKey(url: String): String = url.removePrefix(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_LARGE_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
|
||||
fun getThumbKey(url: String): String = url.removePrefix(thumbPrefix)
|
||||
|
||||
val GalleryPreview.url
|
||||
get() = if (exThumb) {
|
||||
if (this is NormalGalleryPreview) URL_PREFIX_THUMB_EX else URL_PREFIX_LARGE_THUMB_EX
|
||||
val String.isNormalPreviewKey
|
||||
get() = startsWith(NORMAL_PREVIEW_PREFIX)
|
||||
|
||||
val GalleryPreview.imageKey
|
||||
get() = if (this is NormalGalleryPreview) {
|
||||
NormalPreviewKeyRegex.find(url)?.run { NORMAL_PREVIEW_PREFIX + groupValues[1] }
|
||||
} else {
|
||||
URL_PREFIX_THUMB_E
|
||||
} + imageKey
|
||||
getThumbKey(url)
|
||||
}
|
||||
|
||||
val GalleryInfo.thumbUrl
|
||||
get() = (if (exThumb) URL_PREFIX_LARGE_THUMB_EX else URL_PREFIX_THUMB_E) + thumbKey!!
|
||||
get() = thumbPrefix + EhUtils.handleThumbUrlResolution(thumbKey!!)
|
||||
|
||||
val exThumb get() = EhUtils.isExHentai && !Settings.forceEhThumb
|
||||
private val thumbPrefix
|
||||
get() = if (EhUtils.isExHentai) URL_PREFIX_THUMB_EX else URL_PREFIX_THUMB_E
|
||||
@@ -29,6 +29,7 @@ import com.hippo.ehviewer.client.data.FavListUrlBuilder
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.InsufficientFundsException
|
||||
import com.hippo.ehviewer.client.exception.NoHitsFoundException
|
||||
import com.hippo.ehviewer.client.exception.NotLoggedInException
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import com.hippo.ehviewer.client.parser.ArchiveParser
|
||||
@@ -55,11 +56,12 @@ import com.hippo.ehviewer.dailycheck.showEventNotification
|
||||
import com.hippo.ehviewer.dailycheck.today
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.StatusCodeException
|
||||
import com.hippo.ehviewer.util.bodyAsUtf8Text
|
||||
import com.hippo.ehviewer.util.ensureSuccess
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.statement.HttpStatement
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
import io.ktor.utils.io.pool.useInstance
|
||||
import java.io.File
|
||||
@@ -96,7 +98,7 @@ fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
|
||||
fun rethrowExactly(status: HttpStatusCode, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
|
||||
// Don't translate coroutine cancellation
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
@@ -124,14 +126,12 @@ fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): N
|
||||
}
|
||||
|
||||
// Check bad response code
|
||||
if (code >= 400) {
|
||||
throw StatusCodeException(code)
|
||||
}
|
||||
status.ensureSuccess()
|
||||
|
||||
if (e is ParseException || e is SerializationException) {
|
||||
body.onLeft { if ("<" !in it) throw EhException(it) }
|
||||
when (val message = e.cause?.message) {
|
||||
"No hits found!" -> throw EhException(R.string.gallery_list_empty_hit)
|
||||
"No hits found!" -> throw NoHitsFoundException()
|
||||
"No watched tags!" -> throw EhException(R.string.gallery_list_empty_hit_subscription)
|
||||
"Not logged in!" -> throw NotLoggedInException()
|
||||
is String -> if (message.startsWith("Your IP address")) throw EhException(message)
|
||||
@@ -151,7 +151,7 @@ suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend
|
||||
runSuspendCatching {
|
||||
block(body)
|
||||
}.onFailure {
|
||||
rethrowExactly(response.status.value, body.left(), it)
|
||||
rethrowExactly(response.status, body.left(), it)
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: s
|
||||
block(buffer)
|
||||
}.onFailure {
|
||||
buffer.rewind()
|
||||
rethrowExactly(response.status.value, buffer.right(), it)
|
||||
rethrowExactly(response.status, buffer.right(), it)
|
||||
}.getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.hippo.ehviewer.EhApplication.Companion.ktorClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.util.bodyAsUtf8Text
|
||||
import com.hippo.ehviewer.util.ensureSuccess
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.forms.FormDataContent
|
||||
@@ -45,7 +46,11 @@ import okio.BufferedSource
|
||||
|
||||
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
|
||||
|
||||
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely { it.bodyAsUtf8Text().parseAs<T>() }
|
||||
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely {
|
||||
it.status.ensureSuccess()
|
||||
it.bodyAsUtf8Text().parseAs<T>()
|
||||
}
|
||||
|
||||
inline fun <reified T> BufferedSource.parseAs(): T = json.decodeFromBufferedSource(this)
|
||||
inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
|
||||
|
||||
|
||||
@@ -24,14 +24,13 @@ import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.client.data.TagNamespace
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.StatusCodeException
|
||||
import com.hippo.ehviewer.util.copyTo
|
||||
import com.hippo.ehviewer.util.ensureSuccess
|
||||
import com.hippo.ehviewer.util.sha1
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.isSuccess
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -143,11 +142,8 @@ object EhTagDatabase : CoroutineScope {
|
||||
private suspend fun save(client: HttpClient, url: String, file: File) {
|
||||
runCatching {
|
||||
client.prepareGet(url).executeSafely {
|
||||
if (it.status.isSuccess()) {
|
||||
it.bodyAsChannel().copyTo(file)
|
||||
} else {
|
||||
throw StatusCodeException(it.status.value)
|
||||
}
|
||||
it.status.ensureSuccess()
|
||||
it.bodyAsChannel().copyTo(file)
|
||||
}
|
||||
}.onFailure {
|
||||
file.delete()
|
||||
|
||||
@@ -60,14 +60,10 @@ object EhUrl {
|
||||
}
|
||||
|
||||
val apiUrl: String
|
||||
get() = if (Settings.forceEhThumb) {
|
||||
API_E
|
||||
} else {
|
||||
when (Settings.gallerySite) {
|
||||
SITE_E -> API_E
|
||||
SITE_EX -> API_EX
|
||||
else -> API_E
|
||||
}
|
||||
get() = when (Settings.gallerySite) {
|
||||
SITE_E -> API_E
|
||||
SITE_EX -> API_EX
|
||||
else -> API_E
|
||||
}
|
||||
|
||||
val referer: String
|
||||
|
||||
@@ -172,10 +172,7 @@ object EhUtils {
|
||||
return title.substringBeforeLast('|').trim().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun handleThumbUrlResolution(url: String?): String? {
|
||||
if (null == url) {
|
||||
return null
|
||||
}
|
||||
fun handleThumbUrlResolution(url: String): String {
|
||||
val resolution = when (Settings.thumbResolution) {
|
||||
0 -> return url
|
||||
1 -> "250"
|
||||
|
||||
@@ -19,19 +19,19 @@ import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
interface GalleryPreview : Parcelable {
|
||||
val imageKey: String
|
||||
val url: String
|
||||
val position: Int
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class LargeGalleryPreview(
|
||||
override val imageKey: String,
|
||||
override val url: String,
|
||||
override val position: Int,
|
||||
) : GalleryPreview
|
||||
|
||||
@Parcelize
|
||||
data class NormalGalleryPreview(
|
||||
override val imageKey: String,
|
||||
override val url: String,
|
||||
override val position: Int,
|
||||
val offsetX: Int,
|
||||
val clipWidth: Int,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.hippo.ehviewer.client.exception
|
||||
|
||||
import com.hippo.ehviewer.R
|
||||
|
||||
class NoHitsFoundException : EhException(R.string.gallery_list_empty_hit)
|
||||
@@ -16,7 +16,6 @@
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.client.EhUtils.getCategory
|
||||
import com.hippo.ehviewer.client.EhUtils.handleThumbUrlResolution
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.getThumbKey
|
||||
import com.hippo.ehviewer.client.parseAs
|
||||
@@ -32,7 +31,7 @@ object GalleryApiParser {
|
||||
title = item.title.unescapeXml()
|
||||
titleJpn = item.titleJpn.unescapeXml()
|
||||
category = getCategory(item.category)
|
||||
thumbKey = getThumbKey(handleThumbUrlResolution(item.thumb)!!)
|
||||
thumbKey = getThumbKey(item.thumb)
|
||||
uploader = item.uploader?.unescapeXml()
|
||||
posted = ParserUtils.formatDate(item.posted * 1000)
|
||||
rating = item.rating
|
||||
|
||||
@@ -21,7 +21,6 @@ import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhFilter
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.EhUtils.getCategory
|
||||
import com.hippo.ehviewer.client.EhUtils.handleThumbUrlResolution
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryComment
|
||||
import com.hippo.ehviewer.client.data.GalleryCommentList
|
||||
@@ -140,11 +139,7 @@ object GalleryDetailParser {
|
||||
val gm = d.getElementsByClass("gm")[0]
|
||||
// Thumb url
|
||||
gm.getElementById("gd1")?.child(0)?.attr("style")?.trim()?.let {
|
||||
gd.thumbKey = getThumbKey(
|
||||
PATTERN_COVER.find(it)?.run {
|
||||
handleThumbUrlResolution(groupValues[3])
|
||||
}!!,
|
||||
)
|
||||
gd.thumbKey = getThumbKey(PATTERN_COVER.find(it)!!.groupValues[3])
|
||||
}
|
||||
|
||||
gd.title = gm.getElementById("gn")?.text()?.trim()
|
||||
@@ -458,9 +453,9 @@ object GalleryDetailParser {
|
||||
check(PATTERN_LARGE_PREVIEW.containsMatchIn(body))
|
||||
return PATTERN_LARGE_PREVIEW.findAll(body).unzip {
|
||||
val position = it.groupValues[2].toInt() - 1
|
||||
val imageKey = getThumbKey(it.groupValues[3].trim())
|
||||
val url = it.groupValues[3].trim()
|
||||
val pageUrl = it.groupValues[1].trim()
|
||||
LargeGalleryPreview(imageKey, position) to pageUrl
|
||||
LargeGalleryPreview(url, position) to pageUrl
|
||||
}.run {
|
||||
first.toList() to second.toList()
|
||||
}
|
||||
@@ -470,12 +465,12 @@ object GalleryDetailParser {
|
||||
check(PATTERN_NORMAL_PREVIEW.containsMatchIn(body))
|
||||
return PATTERN_NORMAL_PREVIEW.findAll(body).unzip {
|
||||
val position = it.groupValues[6].toInt() - 1
|
||||
val imageKey = getThumbKey(it.groupValues[3].trim())
|
||||
val url = it.groupValues[3].trim()
|
||||
val xOffset = it.groupValues[4].toIntOrNull() ?: 0
|
||||
val width = it.groupValues[1].toInt()
|
||||
val height = it.groupValues[2].toInt()
|
||||
val pageUrl = it.groupValues[5].trim()
|
||||
NormalGalleryPreview(imageKey, position, xOffset, width, height) to pageUrl
|
||||
NormalGalleryPreview(url, position, xOffset, width, height) to pageUrl
|
||||
}.run {
|
||||
first.toList() to second.toList()
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.NormalGalleryPreview
|
||||
import com.hippo.ehviewer.client.url
|
||||
import com.hippo.ehviewer.client.imageKey
|
||||
import com.hippo.ehviewer.spider.DownloadInfoMagics.encodeMagicRequestOrUrl
|
||||
import io.ktor.http.HttpHeaders
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.hippo.ehviewer.coil
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.ImageResult
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.isNormalPreviewKey
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
@@ -41,7 +42,7 @@ object MergeInterceptor : Interceptor {
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val req = chain.request
|
||||
val key = req.memoryCacheKey?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorCoroutineContext) { chain.proceed() }
|
||||
val key = req.memoryCacheKey?.takeIf { it.isNormalPreviewKey || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorCoroutineContext) { chain.proceed() }
|
||||
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
synchronized(pendingContinuationMapLock) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import eu.kanade.tachiyomi.util.system.logcat
|
||||
import java.nio.ByteBuffer
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -54,21 +55,19 @@ class ArchivePageLoader(
|
||||
passwdProvider: PasswdProvider? = null,
|
||||
) : PageLoader2(gid, startPage) {
|
||||
private lateinit var pfd: ParcelFileDescriptor
|
||||
private val hostJob = launch(start = CoroutineStart.LAZY) {
|
||||
private val hostJob = async(start = CoroutineStart.LAZY) {
|
||||
logcat(DEBUG_TAG) { "Open archive ${file.toUri().displayPath}" }
|
||||
pfd = file.openFileDescriptor("r")
|
||||
size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip"))
|
||||
if (size == 0) {
|
||||
return@launch
|
||||
}
|
||||
if (passwdProvider != null && needPassword()) {
|
||||
val size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip"))
|
||||
if (size != 0 && passwdProvider != null && needPassword()) {
|
||||
archivePasswds?.forEach {
|
||||
it ?: return@forEach
|
||||
if (providePassword(it)) return@launch
|
||||
if (providePassword(it)) return@async size
|
||||
}
|
||||
val toAdd = passwdProvider { providePassword(it) }
|
||||
archivePasswds = archivePasswds?.toMutableSet()?.apply { add(toAdd) } ?: setOf(toAdd)
|
||||
}
|
||||
size
|
||||
}
|
||||
|
||||
override var size = 0
|
||||
@@ -136,8 +135,8 @@ class ArchivePageLoader(
|
||||
}
|
||||
|
||||
override suspend fun awaitReady(): Boolean {
|
||||
hostJob.join()
|
||||
return super.awaitReady() && size != 0
|
||||
size = hostJob.await()
|
||||
return super.awaitReady() && isReady
|
||||
}
|
||||
|
||||
override val isReady: Boolean
|
||||
@@ -162,7 +161,7 @@ class ArchivePageLoader(
|
||||
false
|
||||
}
|
||||
|
||||
override fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {}
|
||||
override fun prefetchPages(pages: List<Int>, bounds: Pair<Int, Int>) = Unit
|
||||
}
|
||||
|
||||
private const val DEBUG_TAG = "ArchivePageLoader"
|
||||
@@ -105,7 +105,7 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
|
||||
notifyPageFailed(index, error)
|
||||
}
|
||||
|
||||
override fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {
|
||||
mSpiderQueen.preloadPages(pages, pair)
|
||||
override fun prefetchPages(pages: List<Int>, bounds: Pair<Int, Int>) {
|
||||
mSpiderQueen.preloadPages(pages, bounds)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.gallery
|
||||
|
||||
import androidx.annotation.CallSuper
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
|
||||
@@ -23,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import okio.Path
|
||||
@@ -31,21 +31,19 @@ abstract class PageLoader2(private val gid: Long, var startPage: Int) :
|
||||
PageLoader(),
|
||||
CoroutineScope {
|
||||
override val coroutineContext = Dispatchers.IO + Job()
|
||||
private val progressJob = launch(start = CoroutineStart.LAZY) {
|
||||
private val progressJob = async(start = CoroutineStart.LAZY) {
|
||||
if (startPage == -1) {
|
||||
startPage = EhDB.getReadProgress(gid)
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
super.start()
|
||||
progressJob.start()
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override suspend fun awaitReady(): Boolean {
|
||||
progressJob.join()
|
||||
return startPage != -1
|
||||
progressJob.await()
|
||||
return super.awaitReady() && startPage != -1
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
package com.hippo.ehviewer.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Animatable
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
@@ -51,7 +50,7 @@ import java.nio.ByteBuffer
|
||||
import okio.Path
|
||||
import splitties.init.appCtx
|
||||
|
||||
class Image private constructor(image: CoilImage, private val src: AutoCloseable) {
|
||||
class Image private constructor(image: CoilImage, private val src: ImageSource) {
|
||||
val size = image.size
|
||||
val rect = when (image) {
|
||||
is BitmapImageWithRect -> image.rect
|
||||
@@ -63,14 +62,12 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
|
||||
else -> image
|
||||
}
|
||||
|
||||
var isRecyclable = true
|
||||
|
||||
@Synchronized
|
||||
fun recycle() {
|
||||
when (val image = innerImage ?: return) {
|
||||
is DrawableImage -> {
|
||||
(image.drawable as Animatable).stop()
|
||||
image.drawable.callback = null
|
||||
src.close()
|
||||
}
|
||||
is DrawableImage -> src.close()
|
||||
is BitmapImage -> image.bitmap.recycle()
|
||||
}
|
||||
innerImage = null
|
||||
|
||||
@@ -10,4 +10,4 @@ external fun getExtension(index: Int): String
|
||||
external fun needPassword(): Boolean
|
||||
external fun providePassword(str: String): Boolean
|
||||
external fun closeArchive()
|
||||
external fun archiveFdBatch(fdBatch: IntArray, names: Array<String>, arcFd: Int, size: Int): Boolean
|
||||
external fun archiveFdBatch(fdBatch: IntArray, names: Array<String>, arcFd: Int, size: Int)
|
||||
@@ -15,7 +15,6 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import nl.adaptivity.xmlutil.XmlDeclMode
|
||||
import nl.adaptivity.xmlutil.XmlUtilInternal
|
||||
import nl.adaptivity.xmlutil.core.XmlVersion
|
||||
import nl.adaptivity.xmlutil.newWriter
|
||||
import nl.adaptivity.xmlutil.serialization.XML
|
||||
@@ -26,8 +25,6 @@ import okio.Path
|
||||
const val COMIC_INFO_FILE = "ComicInfo.xml"
|
||||
private const val TAG_ORIGINAL = "original"
|
||||
|
||||
// Workaround for https://youtrack.jetbrains.com/issue/KT-69182
|
||||
@OptIn(XmlUtilInternal::class)
|
||||
private val xml = XML {
|
||||
recommended {
|
||||
ignoreUnknownChildren()
|
||||
@@ -63,6 +60,7 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
|
||||
|
||||
else -> simpleTags?.forEach { tagString ->
|
||||
val (namespace, tag) = tagString.split(':', limit = 2)
|
||||
.takeIf { it.size == 2 } ?: return@forEach // Ignore temp tags that don't have namespace
|
||||
when (val ns = TagNamespace(namespace)) {
|
||||
Artist, Cosplayer -> artists.add(tag)
|
||||
Group -> groups.add(tag)
|
||||
|
||||
@@ -50,6 +50,7 @@ import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -224,7 +225,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
check(!(mReadReference < 0 || mDownloadReference < 0)) { "Mode reference < 0" }
|
||||
}
|
||||
|
||||
private val prepareJob = launch { doPrepare() }
|
||||
private val prepareJob = async { doPrepare() }
|
||||
private val archiveJob = launch(start = CoroutineStart.LAZY) { mSpiderDen.archive() }
|
||||
|
||||
private suspend fun doPrepare() {
|
||||
@@ -235,7 +236,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
|
||||
suspend fun awaitReady(): Boolean {
|
||||
prepareJob.join()
|
||||
prepareJob.await()
|
||||
return isReady
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ package com.hippo.ehviewer.ui
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -54,6 +55,7 @@ import com.hippo.ehviewer.download.downloadDir
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
import com.hippo.ehviewer.download.tempDownloadDir
|
||||
import com.hippo.ehviewer.ui.destinations.ReaderScreenDestination
|
||||
import com.hippo.ehviewer.ui.reader.ReaderScreenArgs
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
@@ -68,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
|
||||
@@ -280,21 +281,14 @@ suspend fun removeFromFavorites(galleryInfo: BaseGalleryInfo) = doModifyFavorite
|
||||
localFavorited = EhDB.containLocalFavorites(galleryInfo.gid),
|
||||
)
|
||||
|
||||
context (Context, DestinationsNavigator)
|
||||
fun navToReader(
|
||||
info: BaseGalleryInfo,
|
||||
page: Int = -1,
|
||||
) {
|
||||
if (Settings.newReader) {
|
||||
navigate(ReaderScreenDestination(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)
|
||||
}
|
||||
}
|
||||
fun DestinationsNavigator.navToReader(info: BaseGalleryInfo, page: Int = -1) =
|
||||
navToReader(ReaderScreenArgs.Gallery(info, page))
|
||||
|
||||
fun DestinationsNavigator.navToReader(uri: Uri) =
|
||||
navToReader(ReaderScreenArgs.Archive(uri))
|
||||
|
||||
private fun DestinationsNavigator.navToReader(args: ReaderScreenArgs) =
|
||||
navigate(ReaderScreenDestination(args)) { launchSingleTop = true }
|
||||
|
||||
context(DialogState, Context, DestinationsNavigator)
|
||||
suspend fun doGalleryInfoAction(info: BaseGalleryInfo) {
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
package com.hippo.ehviewer.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.hippo.ehviewer.ui.screen.implicit
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.NoopTransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.ui.tools.togetherWith
|
||||
import com.hippo.ehviewer.util.findActivity
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@Composable
|
||||
inline fun <R> composing(
|
||||
inline fun <R> AnimatedVisibilityScope.composing(
|
||||
navigator: DestinationsNavigator,
|
||||
block: @Composable context(MainActivity, SnackbarHostState, DialogState, DestinationsNavigator, CoroutineScope)
|
||||
block: @Composable context(MainActivity, SnackbarHostState, DialogState, SharedTransitionScope, TransitionsVisibilityScope, DestinationsNavigator, CoroutineScope)
|
||||
() -> R,
|
||||
) = block(
|
||||
LocalContext.current.run { remember { findActivity<MainActivity>() } },
|
||||
LocalSnackBarHostState.current,
|
||||
LocalDialogState.current,
|
||||
navigator,
|
||||
rememberCoroutineScope(),
|
||||
)
|
||||
) = with(NoopTransitionsVisibilityScope) {
|
||||
togetherWith(implicit<AnimatedVisibilityScope>()) {
|
||||
block(
|
||||
with(LocalContext.current) { remember { findActivity() } },
|
||||
LocalSnackBarHostState.current,
|
||||
LocalDialogState.current,
|
||||
LocalSharedTransitionScope.current,
|
||||
this,
|
||||
navigator,
|
||||
rememberCoroutineScope(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ package com.hippo.ehviewer.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.assist.AssistContent
|
||||
import android.content.ContentResolver.SCHEME_CONTENT
|
||||
import android.content.ContentResolver.SCHEME_FILE
|
||||
import android.content.Intent
|
||||
import android.content.pm.verify.domain.DomainVerificationManager
|
||||
import android.content.pm.verify.domain.DomainVerificationUserState.DOMAIN_STATE_NONE
|
||||
@@ -24,6 +26,8 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -147,7 +151,7 @@ import com.hippo.ehviewer.util.sha1
|
||||
import com.hippo.files.isDirectory
|
||||
import com.hippo.files.toOkioPath
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
|
||||
import com.ramcosta.composedestinations.spec.Direction
|
||||
import com.ramcosta.composedestinations.utils.currentDestinationAsState
|
||||
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
@@ -162,7 +166,7 @@ import moe.tarsin.coroutines.runSuspendCatching
|
||||
import splitties.systemservices.clipboardManager
|
||||
import splitties.systemservices.connectivityManager
|
||||
|
||||
private val navItems = arrayOf<Triple<DirectionDestinationSpec, Int, ImageVector>>(
|
||||
private val navItems = arrayOf<Triple<Direction, Int, ImageVector>>(
|
||||
Triple(HomePageScreenDestination, R.string.homepage, Icons.Default.Home),
|
||||
Triple(SubscriptionScreenDestination, R.string.subscription, EhIcons.Default.Subscriptions),
|
||||
Triple(WhatshotScreenDestination, R.string.whats_hot, Icons.Default.Whatshot),
|
||||
@@ -265,10 +269,19 @@ class MainActivity : EhActivity() {
|
||||
intentFlow.collect { intent ->
|
||||
when (intent.action) {
|
||||
Intent.ACTION_VIEW -> {
|
||||
val url = intent.data?.toString()
|
||||
if (url != null && !navigator.navWithUrl(url)) {
|
||||
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
|
||||
addTextToClipboard(new)
|
||||
val uri = intent.data ?: return@collect
|
||||
when (uri.scheme) {
|
||||
SCHEME_CONTENT, SCHEME_FILE -> {
|
||||
navigator.navToReader(uri)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val url = uri.toString()
|
||||
if (!navigator.navWithUrl(url)) {
|
||||
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
|
||||
addTextToClipboard(new)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SEND -> {
|
||||
@@ -450,12 +463,16 @@ class MainActivity : EhActivity() {
|
||||
drawerState = sideSheetState,
|
||||
gesturesEnabled = sheet != null && drawerEnabled,
|
||||
) {
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
startRoute = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
|
||||
defaultTransitions = rememberEhNavAnim(),
|
||||
navController = navController,
|
||||
)
|
||||
SharedTransitionLayout {
|
||||
CompositionLocalProvider(LocalSharedTransitionScope provides this) {
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
start = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
|
||||
defaultTransitions = rememberEhNavAnim(),
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -537,6 +554,7 @@ val LocalSideSheetState = compositionLocalOf<DrawerState2> { error("CompositionL
|
||||
val LocalDrawerHandle = compositionLocalOf<SnapshotStateList<Int>> { error("CompositionLocal LocalDrawerHandle not present!") }
|
||||
val LocalSnackBarHostState = compositionLocalOf<SnackbarHostState> { error("CompositionLocal LocalSnackBarHostState not present!") }
|
||||
val LocalSnackBarFabPadding = compositionLocalOf<State<Dp>> { error("CompositionLocal LocalSnackBarFabPadding not present!") }
|
||||
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> { error("CompositionLocal LocalSharedTransitionScope not present!") }
|
||||
|
||||
@Composable
|
||||
fun DrawerHandle(enabled: Boolean) {
|
||||
|
||||
@@ -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 {
|
||||
@@ -45,15 +32,17 @@ inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -
|
||||
}
|
||||
}
|
||||
|
||||
private typealias Ty = AnimatedContentTransitionScope<NavBackStackEntry>
|
||||
|
||||
@Composable
|
||||
fun rememberEhNavAnim(): NavHostAnimatedDestinationStyle {
|
||||
val slideDistance = rememberSlideDistance()
|
||||
return remember(slideDistance) {
|
||||
object : NavHostAnimatedDestinationStyle() {
|
||||
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = { materialSharedAxisXIn(true, slideDistance) }
|
||||
override val exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = { materialSharedAxisXOut(true, slideDistance) }
|
||||
override val popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = { materialSharedAxisXIn(false, slideDistance) }
|
||||
override val popExitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = { materialSharedAxisXOut(false, slideDistance) }
|
||||
override val enterTransition: Ty.() -> EnterTransition = { materialSharedAxisXIn(true, slideDistance) }
|
||||
override val exitTransition: Ty.() -> ExitTransition = { materialSharedAxisXOut(true, slideDistance) }
|
||||
override val popEnterTransition: Ty.() -> EnterTransition = { materialSharedAxisXIn(false, slideDistance) }
|
||||
override val popExitTransition: Ty.() -> ExitTransition = { materialSharedAxisXOut(false, slideDistance) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,23 +7,27 @@ import android.widget.TextView
|
||||
import coil3.asDrawable
|
||||
import coil3.imageLoader
|
||||
import coil3.request.crossfade
|
||||
import coil3.size.Size
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
|
||||
class CoilImageGetter(
|
||||
private val textView: TextView,
|
||||
) : Html.ImageGetter {
|
||||
override fun getDrawable(source: String) = object : DrawableWrapper(null) {}.apply {
|
||||
textView.context.imageLoader.enqueue(
|
||||
textView.context.imageRequest {
|
||||
data(source)
|
||||
crossfade(false)
|
||||
target { drawable ->
|
||||
setDrawable(drawable.asDrawable(textView.resources))
|
||||
if (drawable is Animatable) drawable.start()
|
||||
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
|
||||
textView.text = textView.text
|
||||
}
|
||||
},
|
||||
)
|
||||
with(textView.context) {
|
||||
imageLoader.enqueue(
|
||||
imageRequest {
|
||||
data(source)
|
||||
crossfade(false)
|
||||
size(Size.ORIGINAL)
|
||||
target { drawable ->
|
||||
setDrawable(drawable.asDrawable(resources))
|
||||
if (drawable is Animatable) drawable.start()
|
||||
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
|
||||
textView.text = textView.text
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -43,6 +44,7 @@ import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
|
||||
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
|
||||
private val ids = Tuple9(1, 2, 3, 4, 5, 6, 7, 8, 9)
|
||||
@@ -90,6 +92,7 @@ private val constraintSet = ConstraintSet {
|
||||
}
|
||||
}
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
@Composable
|
||||
fun DownloadCard(
|
||||
onClick: () -> Unit,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.material3.ShapeDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -7,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil3.Image
|
||||
@@ -14,6 +17,7 @@ import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.ui.tools.shouldCrop
|
||||
|
||||
@Composable
|
||||
@@ -23,6 +27,7 @@ fun requestOf(model: GalleryInfo): ImageRequest {
|
||||
return remember(model) { context.imageRequest(model) }
|
||||
}
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun EhAsyncThumb(
|
||||
@@ -33,13 +38,17 @@ fun EhAsyncThumb(
|
||||
) = AsyncImage(
|
||||
model = requestOf(model),
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
modifier = modifier
|
||||
// https://github.com/FooIbar/EhViewer/issues/1527
|
||||
// .sharedBounds(key = "${model.gid}")
|
||||
.clip(ShapeDefaults.Medium),
|
||||
onSuccess = onSuccess?.let { callback ->
|
||||
{ callback(it.result.image) }
|
||||
},
|
||||
contentScale = contentScale,
|
||||
)
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
@Composable
|
||||
fun EhAsyncCropThumb(
|
||||
key: GalleryInfo,
|
||||
@@ -49,7 +58,10 @@ fun EhAsyncCropThumb(
|
||||
AsyncImage(
|
||||
model = requestOf(key),
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
modifier = modifier
|
||||
// https://github.com/FooIbar/EhViewer/issues/1527
|
||||
// .sharedBounds(key = "${key.gid}")
|
||||
.clip(ShapeDefaults.Medium),
|
||||
onSuccess = {
|
||||
if (it.result.image.shouldCrop) {
|
||||
contentScale = ContentScale.Crop
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -9,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Label
|
||||
@@ -36,6 +36,7 @@ import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.icons.EhIcons
|
||||
import com.hippo.ehviewer.icons.big.SadAndroid
|
||||
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
|
||||
|
||||
@Composable
|
||||
fun GalleryDetailHeaderInfoCard(
|
||||
@@ -96,6 +97,7 @@ fun GalleryDetailHeaderInfoCard(
|
||||
}
|
||||
}
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
@Composable
|
||||
fun GalleryDetailHeaderCard(
|
||||
info: GalleryInfo,
|
||||
@@ -109,7 +111,10 @@ fun GalleryDetailHeaderCard(
|
||||
Card {
|
||||
EhAsyncCropThumb(
|
||||
key = remember(info.gid) { info },
|
||||
modifier = Modifier.height(dimensionResource(id = R.dimen.gallery_detail_thumb_height)).width(dimensionResource(id = R.dimen.gallery_detail_thumb_width)),
|
||||
modifier = Modifier.size(
|
||||
dimensionResource(id = R.dimen.gallery_detail_thumb_width),
|
||||
dimensionResource(id = R.dimen.gallery_detail_thumb_height),
|
||||
),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(0.5F))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -51,6 +52,7 @@ import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.ElevatedCard
|
||||
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
|
||||
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
|
||||
private val ids = Tuple7(1, 2, 3, 4, 5, 6, 7)
|
||||
@@ -88,6 +90,7 @@ private val constraintSet = ConstraintSet {
|
||||
}
|
||||
}
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
@Composable
|
||||
fun GalleryInfoListItem(
|
||||
onClick: () -> Unit,
|
||||
@@ -199,6 +202,7 @@ fun GalleryInfoListItem(
|
||||
}
|
||||
}
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
@Composable
|
||||
fun GalleryInfoGridItem(
|
||||
onClick: () -> Unit,
|
||||
|
||||
@@ -53,6 +53,7 @@ import androidx.paging.compose.itemKey
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.exception.NoHitsFoundException
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.icons.EhIcons
|
||||
import com.hippo.ehviewer.icons.big.SadAndroid
|
||||
@@ -116,6 +117,11 @@ fun GalleryList(
|
||||
}
|
||||
},
|
||||
) {
|
||||
val showLoadStateIndicator = when (val state = data.loadState.append) {
|
||||
LoadState.Loading -> true
|
||||
is LoadState.Error -> state.error !is NoHitsFoundException
|
||||
is LoadState.NotLoading -> false
|
||||
}
|
||||
if (listMode == 0) {
|
||||
val columnWidth by collectDetailSizeAsState()
|
||||
FastScrollLazyVerticalGrid(
|
||||
@@ -136,7 +142,7 @@ fun GalleryList(
|
||||
detailItemContent(info)
|
||||
}
|
||||
}
|
||||
if (data.loadState.append !is LoadState.NotLoading) {
|
||||
if (showLoadStateIndicator) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
LoadStateIndicator(state = data.loadState.append) {
|
||||
data.retry()
|
||||
@@ -165,7 +171,7 @@ fun GalleryList(
|
||||
thumbItemContent(info)
|
||||
}
|
||||
}
|
||||
if (data.loadState.append !is LoadState.NotLoading) {
|
||||
if (showLoadStateIndicator) {
|
||||
item(span = StaggeredGridItemSpan.FullLine) {
|
||||
LoadStateIndicator(state = data.loadState.append) {
|
||||
data.retry()
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
@@ -26,6 +27,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -151,23 +153,27 @@ fun SearchFilter(
|
||||
val (from, to) = dialogState.awaitResult(
|
||||
initial = advancedOption.fromPage to advancedOption.toPage,
|
||||
title = R.string.key_pages,
|
||||
invalidator = { (min, _max) ->
|
||||
// 0 means max pages not filled, though +inf
|
||||
val max = if (_max == 0) Int.MAX_VALUE else _max
|
||||
if (min != 0) {
|
||||
ensure(max - min >= 20) { pageErr2 }
|
||||
} else {
|
||||
ensure(max >= 10) { pageErr1 }
|
||||
invalidator = { (min, max) ->
|
||||
if (max != 0) {
|
||||
if (min != 0) {
|
||||
ensure(min <= (max / 2).coerceAtMost(max - 20)) { pageErr2 }
|
||||
} else {
|
||||
ensure(max >= 10) { pageErr1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
) { error ->
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
value = expectedValue.first.takeIf { it > 0 }?.toString().orEmpty(),
|
||||
onValueChange = {
|
||||
expectedValue = expectedValue.copy(first = it.toIntOrDefault(0).coerceAtLeast(0))
|
||||
expectedValue = expectedValue.copy(first = it.toIntOrDefault(0).coerceIn(0, 1000))
|
||||
},
|
||||
modifier = Modifier.width(112.dp).padding(16.dp),
|
||||
singleLine = true,
|
||||
@@ -178,7 +184,7 @@ fun SearchFilter(
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
value = expectedValue.second.takeIf { it > 0 }?.toString().orEmpty(),
|
||||
onValueChange = {
|
||||
expectedValue = expectedValue.copy(second = it.toIntOrDefault(0).coerceAtLeast(0))
|
||||
expectedValue = expectedValue.copy(second = it.toIntOrDefault(0).coerceIn(0, 2000))
|
||||
},
|
||||
modifier = Modifier.width(112.dp).padding(16.dp),
|
||||
singleLine = true,
|
||||
@@ -188,11 +194,14 @@ fun SearchFilter(
|
||||
}
|
||||
if (error != null) {
|
||||
ListItem(
|
||||
headlineContent = { Text(text = error) },
|
||||
headlineContent = {
|
||||
Text(text = error, style = MaterialTheme.typography.bodySmall)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(imageVector = Icons.Default.Info, contentDescription = null)
|
||||
},
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
headlineColor = MaterialTheme.colorScheme.error,
|
||||
leadingIconColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import com.hippo.ehviewer.Settings
|
||||
import me.saket.telephoto.zoomable.DoubleClickToZoomListener
|
||||
import me.saket.telephoto.zoomable.ZoomableState
|
||||
|
||||
object DoubleTapZoom : DoubleClickToZoomListener {
|
||||
override suspend fun onDoubleClick(state: ZoomableState, centroid: Offset) {
|
||||
if (Settings.doubleTapToZoom.value) {
|
||||
val zoomFraction = state.zoomFraction ?: return // Content isn't ready yet
|
||||
if (zoomFraction > 0.05f) {
|
||||
state.resetZoom()
|
||||
} else {
|
||||
// Workaround for https://github.com/saket/telephoto/issues/45
|
||||
state.zoomTo(
|
||||
zoomFactor = state.contentTransformation.scaleMetadata.initialScale.scaleX * 2f,
|
||||
centroid = centroid,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,27 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.VerticalPager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.gallery.PageLoader2
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.CONTINUOUS_VERTICAL
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.DEFAULT
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.LEFT_TO_RIGHT
|
||||
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 me.saket.telephoto.zoomable.ZoomSpec
|
||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
import me.saket.telephoto.zoomable.zoomable
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.TappingInvertMode
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
|
||||
@Composable
|
||||
fun GalleryPager(
|
||||
@@ -37,119 +29,57 @@ fun GalleryPager(
|
||||
pagerState: PagerState,
|
||||
lazyListState: LazyListState,
|
||||
pageLoader: PageLoader2,
|
||||
showNavigationOverlay: Boolean,
|
||||
onNavigationModeChange: () -> Unit,
|
||||
onSelectPage: (ReaderPage) -> Unit,
|
||||
onMenuRegionClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
) {
|
||||
val items = pageLoader.pages
|
||||
when (type) {
|
||||
DEFAULT, LEFT_TO_RIGHT, RIGHT_TO_LEFT -> {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
reverseLayout = type == RIGHT_TO_LEFT,
|
||||
key = { it },
|
||||
) { index ->
|
||||
val page = items[index]
|
||||
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
|
||||
val status by page.status.collectAsState()
|
||||
val boxModifier = if (status == Page.State.READY) {
|
||||
Modifier.zoomable(
|
||||
state = zoomableState,
|
||||
onClick = { onMenuRegionClick() },
|
||||
onLongClick = { onSelectPage(page) },
|
||||
)
|
||||
} else {
|
||||
Modifier.combinedClickable(
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onClick = { onMenuRegionClick() },
|
||||
).fillMaxSize()
|
||||
}
|
||||
Box(
|
||||
modifier = boxModifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PagerItem(
|
||||
page = page,
|
||||
pageLoader = pageLoader,
|
||||
zoomableState = zoomableState,
|
||||
webtoon = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
VERTICAL -> {
|
||||
VerticalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
key = { it },
|
||||
) { index ->
|
||||
val page = items[index]
|
||||
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
|
||||
val status by page.status.collectAsState()
|
||||
val boxModifier = if (status == Page.State.READY) {
|
||||
Modifier.zoomable(
|
||||
state = zoomableState,
|
||||
onClick = { onMenuRegionClick() },
|
||||
onLongClick = { onSelectPage(page) },
|
||||
)
|
||||
} else {
|
||||
Modifier.combinedClickable(
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onClick = { onMenuRegionClick() },
|
||||
).fillMaxSize()
|
||||
}
|
||||
Box(
|
||||
modifier = boxModifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PagerItem(
|
||||
page = page,
|
||||
pageLoader = pageLoader,
|
||||
zoomableState = zoomableState,
|
||||
webtoon = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
WEBTOON, CONTINUOUS_VERTICAL -> {
|
||||
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
|
||||
LazyColumn(
|
||||
modifier = modifier.zoomable(
|
||||
state = zoomableState,
|
||||
onClick = { onMenuRegionClick() },
|
||||
onLongClick = { ofs ->
|
||||
val info = lazyListState.layoutInfo.visibleItemsInfo.find { info ->
|
||||
info.offset <= ofs.y && info.offset + info.size > ofs.y
|
||||
}
|
||||
if (info == null && type == CONTINUOUS_VERTICAL) {
|
||||
// Maybe user long-click on gaps? ¯\_(ツ)_/¯
|
||||
return@zoomable
|
||||
}
|
||||
info ?: error("Internal error finding long click item!!! offset:$ofs")
|
||||
onSelectPage(items[info.index])
|
||||
},
|
||||
),
|
||||
state = lazyListState,
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(if (type != WEBTOON) 15.dp else 0.dp),
|
||||
) {
|
||||
items(items, key = { it.index }) { page ->
|
||||
PagerItem(
|
||||
page = page,
|
||||
pageLoader = pageLoader,
|
||||
zoomableState = zoomableState,
|
||||
webtoon = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val isPagerType = !ReadingModeType.isWebtoon(type)
|
||||
val pagerNavigation by Settings.readerPagerNav.collectAsState()
|
||||
val pagerInvertMode by Settings.readerPagerNavInverted.collectAsState()
|
||||
val webtoonNavigation by Settings.readerWebtoonNav.collectAsState()
|
||||
val webtoonInvertMode by Settings.readerWebtoonNavInverted.collectAsState()
|
||||
val navigationType = if (isPagerType) pagerNavigation else webtoonNavigation
|
||||
val navigation = remember(navigationType, type) {
|
||||
ViewerNavigation.fromPreference(navigationType, ReadingModeType.isVertical(type))
|
||||
}
|
||||
val invertMode = if (isPagerType) pagerInvertMode else webtoonInvertMode
|
||||
var firstLaunch by remember { mutableStateOf(true) }
|
||||
val regions = remember(navigation, invertMode) {
|
||||
if (firstLaunch) {
|
||||
firstLaunch = false
|
||||
} else {
|
||||
onNavigationModeChange()
|
||||
}
|
||||
navigation.invertMode = TappingInvertMode.entries[invertMode]
|
||||
navigation.regions
|
||||
}
|
||||
val navigator by rememberUpdatedState(regions)
|
||||
if (isPagerType) {
|
||||
PagerViewer(
|
||||
pagerState = pagerState,
|
||||
isRtl = type == RIGHT_TO_LEFT,
|
||||
isVertical = type == VERTICAL,
|
||||
pageLoader = pageLoader,
|
||||
navigator = { navigator },
|
||||
onSelectPage = onSelectPage,
|
||||
onMenuRegionClick = onMenuRegionClick,
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
WebtoonViewer(
|
||||
lazyListState = lazyListState,
|
||||
withGaps = type == CONTINUOUS_VERTICAL,
|
||||
pageLoader = pageLoader,
|
||||
navigator = { navigator },
|
||||
onSelectPage = onSelectPage,
|
||||
onMenuRegionClick = onMenuRegionClick,
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
NavigationOverlay(showNavigationOverlay, regions, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
private val zoomSpec = ZoomSpec(maxZoomFactor = 3f)
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
|
||||
|
||||
@Composable
|
||||
fun NavigationOverlay(
|
||||
visible: Boolean,
|
||||
regions: NavigationRegions,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible && regions.isNotEmpty(),
|
||||
enter = fadeIn(AnimationSpec),
|
||||
exit = fadeOut(AnimationSpec),
|
||||
) {
|
||||
BoxWithConstraints(modifier) {
|
||||
val fontSize = MaterialTheme.typography.headlineSmall.fontSize
|
||||
val style = TextStyle(
|
||||
color = Color.White,
|
||||
fontSize = fontSize,
|
||||
)
|
||||
val strokeStyle = TextStyle(
|
||||
color = Color.Black,
|
||||
fontSize = fontSize,
|
||||
drawStyle = Stroke(8f),
|
||||
)
|
||||
val scaleX = maxWidth
|
||||
val scaleY = maxHeight
|
||||
regions.forEach { region ->
|
||||
val rect = region.rectF
|
||||
val color = colorResource(region.type.colorRes)
|
||||
val text = stringResource(region.type.nameRes)
|
||||
Box(
|
||||
modifier = Modifier.size(scaleX * rect.width(), scaleY * rect.height())
|
||||
.offset(scaleX * rect.left, scaleY * rect.top).background(color),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text, style = strokeStyle)
|
||||
Text(text, style = style)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val AnimationSpec = tween<Float>(1000)
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -24,39 +25,37 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.BitmapImage
|
||||
import coil3.DrawableImage
|
||||
import coil3.Image
|
||||
import com.google.accompanist.drawablepainter.DrawablePainter
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.image.Image
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.CombinedCircularProgressIndicator
|
||||
import me.saket.telephoto.zoomable.ZoomableContentLocation
|
||||
import me.saket.telephoto.zoomable.ZoomableState
|
||||
import moe.tarsin.kt.unreachable
|
||||
|
||||
@Composable
|
||||
fun PagerItem(
|
||||
page: ReaderPage,
|
||||
pageLoader: PageLoader,
|
||||
zoomableState: ZoomableState,
|
||||
webtoon: Boolean,
|
||||
contentScale: ContentScale,
|
||||
modifier: Modifier = Modifier,
|
||||
contentModifier: Modifier = Modifier,
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
pageLoader.request(page.index)
|
||||
}
|
||||
val defaultError = stringResource(id = R.string.decode_image_error)
|
||||
val state by page.status.collectAsState()
|
||||
when (state) {
|
||||
Page.State.QUEUE, Page.State.LOAD_PAGE, Page.State.DOWNLOAD_IMAGE -> {
|
||||
LaunchedEffect(Unit) {
|
||||
pageLoader.request(page.index)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT),
|
||||
modifier = modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val progress by page.progressFlow.collectAsState()
|
||||
@@ -64,16 +63,25 @@ fun PagerItem(
|
||||
}
|
||||
}
|
||||
Page.State.READY -> {
|
||||
val image = page.image!!.innerImage!!
|
||||
val rect = page.image!!.rect
|
||||
val painter = remember(image) { image.toPainter(rect) }
|
||||
val contentScale = if (webtoon) ContentScale.FillWidth else ContentScale.Inside
|
||||
val image = page.image!!
|
||||
val painter = remember(image) { image.toPainter() }
|
||||
val grayScale by Settings.grayScale.collectAsState()
|
||||
val invert by Settings.invertedColors.collectAsState()
|
||||
DisposableEffect(image) {
|
||||
image.isRecyclable = false
|
||||
onDispose {
|
||||
if (image.isRecyclable) {
|
||||
pageLoader.notifyPageWait(page.index)
|
||||
image.recycle()
|
||||
} else {
|
||||
image.isRecyclable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
Image(
|
||||
painter = painter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = contentModifier.fillMaxSize(),
|
||||
contentScale = contentScale,
|
||||
colorFilter = when {
|
||||
grayScale && invert -> grayScaleAndInvertFilter
|
||||
@@ -82,16 +90,9 @@ fun PagerItem(
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
if (!webtoon) {
|
||||
val size = painter.intrinsicSize
|
||||
LaunchedEffect(size) {
|
||||
val contentLocation = ZoomableContentLocation.scaledInsideAndCenterAligned(size)
|
||||
zoomableState.setContentLocation(contentLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
Page.State.ERROR -> {
|
||||
Box(modifier = Modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT)) {
|
||||
Box(modifier = modifier.fillMaxWidth().aspectRatio(DEFAULT_ASPECT)) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -111,13 +112,13 @@ fun PagerItem(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Image.toPainter(rect: IntRect) = when (this) {
|
||||
private fun Image.toPainter() = when (val image = innerImage) {
|
||||
is BitmapImage -> BitmapPainter(
|
||||
image = bitmap.asImageBitmap(),
|
||||
image = image.bitmap.asImageBitmap(),
|
||||
srcOffset = rect.topLeft,
|
||||
srcSize = rect.size,
|
||||
)
|
||||
is DrawableImage -> DrawablePainter(drawable)
|
||||
is DrawableImage -> DrawablePainter(image.drawable)
|
||||
else -> unreachable()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.VerticalPager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.toRect
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.FixedScale
|
||||
import androidx.compose.ui.layout.times
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import arrow.core.partially1
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.gallery.PageLoader2
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.getAction
|
||||
import kotlin.contracts.contract
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import me.saket.telephoto.zoomable.ZoomSpec
|
||||
import me.saket.telephoto.zoomable.ZoomableContentLocation
|
||||
import me.saket.telephoto.zoomable.ZoomableState
|
||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
import me.saket.telephoto.zoomable.zoomable
|
||||
|
||||
@Composable
|
||||
fun PagerViewer(
|
||||
pagerState: PagerState,
|
||||
isRtl: Boolean,
|
||||
isVertical: Boolean,
|
||||
pageLoader: PageLoader2,
|
||||
navigator: () -> NavigationRegions,
|
||||
onSelectPage: (ReaderPage) -> Unit,
|
||||
onMenuRegionClick: () -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val items = pageLoader.pages
|
||||
val scaleType by Settings.imageScaleType.collectAsState()
|
||||
val landscapeZoom by Settings.landscapeZoom.collectAsState()
|
||||
val zoomStart by Settings.zoomStart.collectAsState()
|
||||
val alignment = Alignment.fromPreferences(zoomStart, isRtl, isVertical)
|
||||
val layoutSize by remember(pagerState) {
|
||||
derivedStateOf {
|
||||
pagerState.layoutInfo.viewportSize.toSize()
|
||||
}
|
||||
}
|
||||
if (isVertical) {
|
||||
VerticalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
key = { it },
|
||||
) { index ->
|
||||
val page = items[index]
|
||||
PageContainer(
|
||||
page = page,
|
||||
pageLoader = pageLoader,
|
||||
isRtl = false,
|
||||
scaleType = scaleType,
|
||||
landscapeZoom = landscapeZoom,
|
||||
alignment = alignment,
|
||||
layoutSize = layoutSize,
|
||||
navigator = navigator,
|
||||
pagerState = pagerState,
|
||||
onSelectPage = onSelectPage,
|
||||
onMenuRegionClick = onMenuRegionClick,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val isRtlLayout = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
reverseLayout = isRtl xor isRtlLayout,
|
||||
key = { it },
|
||||
) { index ->
|
||||
val page = items[index]
|
||||
PageContainer(
|
||||
page = page,
|
||||
pageLoader = pageLoader,
|
||||
isRtl = isRtl,
|
||||
scaleType = scaleType,
|
||||
landscapeZoom = landscapeZoom,
|
||||
alignment = alignment,
|
||||
layoutSize = layoutSize,
|
||||
navigator = navigator,
|
||||
pagerState = pagerState,
|
||||
onSelectPage = onSelectPage,
|
||||
onMenuRegionClick = onMenuRegionClick,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PageContainer(
|
||||
page: ReaderPage,
|
||||
pageLoader: PageLoader2,
|
||||
isRtl: Boolean,
|
||||
scaleType: Int,
|
||||
landscapeZoom: Boolean,
|
||||
alignment: Alignment.Horizontal,
|
||||
layoutSize: Size,
|
||||
navigator: () -> NavigationRegions,
|
||||
pagerState: PagerState,
|
||||
onSelectPage: (ReaderPage) -> Unit,
|
||||
onMenuRegionClick: () -> Unit,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val isRtl by rememberUpdatedState(isRtl)
|
||||
val zoomableState = rememberZoomableState(zoomSpec = PagerZoomSpec)
|
||||
val status by page.status.collectAsState()
|
||||
if (status == Page.State.READY && layoutSize != Size.Zero) {
|
||||
val size = page.image!!.rect.size.toSize()
|
||||
val contentScale = ContentScale.fromPreferences(scaleType, size, layoutSize)
|
||||
zoomableState.contentScale = contentScale
|
||||
LaunchedEffect(size, contentScale, alignment) {
|
||||
val contentSize = if (contentScale is FixedScale) { // Original
|
||||
size
|
||||
} else {
|
||||
size * contentScale.computeScaleFactor(size, layoutSize)
|
||||
}
|
||||
val horizontalAlignment = if (contentSize.width > layoutSize.width) {
|
||||
alignment
|
||||
} else {
|
||||
Alignment.CenterHorizontally
|
||||
}
|
||||
val verticalAlignment = if (contentSize.height > layoutSize.height) {
|
||||
Alignment.Top
|
||||
} else {
|
||||
Alignment.CenterVertically
|
||||
}
|
||||
zoomableState.contentAlignment = horizontalAlignment + verticalAlignment
|
||||
}
|
||||
LaunchedEffect(size) {
|
||||
val contentLocation = ZoomableContentLocation.scaledInsideAndCenterAligned(size)
|
||||
zoomableState.setContentLocation(contentLocation)
|
||||
}
|
||||
if (landscapeZoom && contentScale == ContentScale.Fit && size.width > size.height) {
|
||||
LaunchedEffect(alignment) {
|
||||
val zoomFraction = snapshotFlow { zoomableState.zoomFraction }.first { it != null }
|
||||
if (zoomFraction == 0f) {
|
||||
delay(500)
|
||||
val contentSize = zoomableState.transformedContentBounds.size
|
||||
val scale = ContentScale.FillHeight.computeScaleFactor(contentSize, layoutSize)
|
||||
val targetScale = scale.scaleX.coerceAtMost(zoomableState.zoomSpec.maxZoomFactor)
|
||||
val offset = alignment.align(0, layoutSize.width.toInt(), LayoutDirection.Ltr)
|
||||
zoomableState.zoomTo(targetScale, Offset(offset.toFloat(), 0f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val onLongClick = { _: Offset -> onSelectPage(page) }
|
||||
val onTap: ZoomableState?.(Offset) -> Unit = { offset ->
|
||||
scope.launch {
|
||||
with(pagerState) {
|
||||
// Don't use `layoutSize` as it may capture outdated value
|
||||
val size = layoutInfo.viewportSize.toSize()
|
||||
val x = offset.x / size.width
|
||||
val y = offset.y / size.height
|
||||
val distance = size.width
|
||||
val bounds = size.toRect()
|
||||
when (navigator().getAction(x, y)) {
|
||||
NavigationRegion.MENU -> onMenuRegionClick()
|
||||
NavigationRegion.NEXT -> {
|
||||
val canPan = if (isRtl) panRight(distance, bounds) else panLeft(distance, bounds)
|
||||
if (!canPan) {
|
||||
moveToNext()
|
||||
}
|
||||
}
|
||||
|
||||
NavigationRegion.PREV -> {
|
||||
val canPan = if (isRtl) panLeft(distance, bounds) else panRight(distance, bounds)
|
||||
if (!canPan) {
|
||||
moveToPrevious()
|
||||
}
|
||||
}
|
||||
|
||||
NavigationRegion.RIGHT -> {
|
||||
if (!panLeft(distance, bounds)) {
|
||||
if (isRtl) moveToPrevious() else moveToNext()
|
||||
}
|
||||
}
|
||||
|
||||
NavigationRegion.LEFT -> {
|
||||
if (!panRight(distance, bounds)) {
|
||||
if (isRtl) moveToNext() else moveToPrevious()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
PagerItem(
|
||||
page = page,
|
||||
pageLoader = pageLoader,
|
||||
contentScale = ContentScale.Inside,
|
||||
modifier = Modifier.pointerInput(onTap) {
|
||||
detectTapGestures(onLongPress = onLongClick, onTap = onTap.partially1(null))
|
||||
},
|
||||
contentModifier = Modifier.zoomable(
|
||||
state = zoomableState,
|
||||
onClick = onTap.partially1(zoomableState),
|
||||
onLongClick = onLongClick,
|
||||
onDoubleClick = DoubleTapZoom,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ZoomableState?.panLeft(distance: Float, bounds: Rect): Boolean =
|
||||
if (canPan { it.right - bounds.right }) {
|
||||
panBy(Offset(-distance, 0f))
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
private suspend fun ZoomableState?.panRight(distance: Float, bounds: Rect): Boolean =
|
||||
if (canPan { bounds.left - it.left }) {
|
||||
panBy(Offset(distance, 0f))
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
private inline fun ZoomableState?.canPan(getRemaining: (Rect) -> Float): Boolean {
|
||||
// TODO: Remove when K2 mode in IDE is stable
|
||||
contract {
|
||||
returns(true) implies (this@canPan != null)
|
||||
}
|
||||
return this != null && Settings.navigateToPan.value && getRemaining(transformedContentBounds) > 1f
|
||||
}
|
||||
|
||||
private val PagerZoomSpec = ZoomSpec(maxZoomFactor = 5f)
|
||||
@@ -1,7 +1,13 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -18,9 +24,11 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.BottomSheetDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -28,17 +36,23 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.ensure
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
@@ -48,8 +62,12 @@ 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.composing
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.Await
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.hippo.files.toOkioPath
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
@@ -59,27 +77,76 @@ import eu.kanade.tachiyomi.ui.reader.ReaderContentOverlay
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderPageSheetMeta
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import moe.tarsin.kt.unreachable
|
||||
|
||||
sealed interface ReaderScreenArgs : Parcelable {
|
||||
@Parcelize
|
||||
data class Gallery(val info: BaseGalleryInfo, val page: Int) : ReaderScreenArgs
|
||||
|
||||
@Parcelize
|
||||
data class Archive(val uri: Uri) : ReaderScreenArgs
|
||||
}
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
ConfigureKeepScreenOn()
|
||||
val pageLoader = remember {
|
||||
val archive = DownloadManager.getDownloadInfo(info.gid)?.archiveFile
|
||||
archive?.let { ArchivePageLoader(it, info.gid, page) } ?: EhPageLoader(info, page)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
Settings.cropBorder.changesFlow().collect {
|
||||
pageLoader.restart()
|
||||
fun AnimatedVisibilityScope.ReaderScreen(args: ReaderScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
Await({ preparePageLoader(args) }) { pageLoader ->
|
||||
DisposableEffect(pageLoader) {
|
||||
pageLoader.start()
|
||||
onDispose {
|
||||
pageLoader.stop()
|
||||
}
|
||||
}
|
||||
val bgColor by collectBackgroundColorAsState()
|
||||
val readingFailed = stringResource(R.string.error_reading_failed)
|
||||
Await(
|
||||
{
|
||||
Either.catch {
|
||||
readingFailed.takeUnless { pageLoader.awaitReady() }
|
||||
}.fold({ it.displayString() }, { it })
|
||||
},
|
||||
placeholder = {
|
||||
Box(Modifier.fillMaxSize().background(bgColor), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
},
|
||||
) { error ->
|
||||
if (error == null) {
|
||||
val info = (args as? ReaderScreenArgs.Gallery)?.info
|
||||
key(pageLoader) {
|
||||
ReaderScreen(pageLoader, info, navigator)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize().background(bgColor), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(pageLoader) {
|
||||
pageLoader.start()
|
||||
onDispose {
|
||||
pageLoader.stop()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnimatedVisibilityScope.ReaderScreen(pageLoader: PageLoader2, info: BaseGalleryInfo?, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
ConfigureKeepScreenOn()
|
||||
LaunchedEffect(Unit) {
|
||||
val orientation = requestedOrientation
|
||||
Settings.orientationMode.valueFlow()
|
||||
.onCompletion { requestedOrientation = orientation }
|
||||
.collect { setOrientation(it) }
|
||||
}
|
||||
LaunchedEffect(pageLoader) {
|
||||
Settings.cropBorder.changesFlow().collect {
|
||||
pageLoader.restart()
|
||||
}
|
||||
}
|
||||
val showSeekbar by Settings.showReaderSeekbar.collectAsState()
|
||||
@@ -95,132 +162,181 @@ fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsN
|
||||
uiController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
|
||||
}
|
||||
}
|
||||
Deferred({ pageLoader.awaitReady() }) {
|
||||
val lazyListState = rememberLazyListState()
|
||||
val pagerState = rememberPagerState { pageLoader.size }
|
||||
val syncState = rememberSliderPagerDoubleSyncState(lazyListState, pagerState, pageLoader)
|
||||
syncState.Sync(ReadingModeType.isWebtoon(readingMode))
|
||||
Box {
|
||||
var appbarVisible by remember { mutableStateOf(false) }
|
||||
val bgColor by collectBackgroundColorAsState()
|
||||
if (fullscreen) {
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { appbarVisible }.collect {
|
||||
uiController.isSystemBarsVisible = it
|
||||
}
|
||||
val lazyListState = rememberLazyListState(pageLoader.startPage)
|
||||
val pagerState = rememberPagerState(pageLoader.startPage) { pageLoader.size }
|
||||
val syncState = rememberSliderPagerDoubleSyncState(lazyListState, pagerState, pageLoader)
|
||||
Box {
|
||||
var appbarVisible by remember { mutableStateOf(false) }
|
||||
val bgColor by collectBackgroundColorAsState()
|
||||
val isWebtoon by rememberUpdatedState(ReadingModeType.isWebtoon(readingMode))
|
||||
syncState.Sync(isWebtoon) { appbarVisible = false }
|
||||
if (fullscreen) {
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { appbarVisible }.collect {
|
||||
uiController.isSystemBarsVisible = it
|
||||
}
|
||||
}
|
||||
VolumeKeysHandler(
|
||||
enabled = { volumeKeysEnabled && !appbarVisible },
|
||||
movePrevious = { syncState.sliderScrollTo(syncState.sliderValue - 1) },
|
||||
moveNext = { syncState.sliderScrollTo(syncState.sliderValue + 1) },
|
||||
)
|
||||
GalleryPager(
|
||||
type = readingMode,
|
||||
pagerState = pagerState,
|
||||
lazyListState = lazyListState,
|
||||
pageLoader = pageLoader,
|
||||
onSelectPage = { page ->
|
||||
if (Settings.readerLongTapAction.value) {
|
||||
launch {
|
||||
dialog { cont ->
|
||||
fun dispose() = cont.resume(Unit)
|
||||
val state = rememberModalBottomSheetState()
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { dispose() },
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
|
||||
sheetState = state,
|
||||
contentWindowInsets = { WindowInsets(0) },
|
||||
) {
|
||||
ReaderPageSheetMeta(
|
||||
retry = { pageLoader.retryPage(page.index) },
|
||||
retryOrigin = { pageLoader.retryPage(page.index, true) },
|
||||
share = { launchIO { with(pageLoader) { shareImage(page, info) } } },
|
||||
copy = { launchIO { with(pageLoader) { copy(page) } } },
|
||||
save = { launchIO { with(pageLoader) { save(page) } } },
|
||||
saveTo = { launchIO { with(pageLoader) { saveTo(page) } } },
|
||||
dismiss = { launch { state.hide().also { dispose() } } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onMenuRegionClick = { appbarVisible = !appbarVisible },
|
||||
modifier = Modifier.background(bgColor),
|
||||
contentPadding = if (fullscreen) {
|
||||
if (cutoutShort) {
|
||||
PaddingValues(0.dp)
|
||||
} else {
|
||||
WindowInsets.displayCutout.asPaddingValues()
|
||||
}
|
||||
} else {
|
||||
WindowInsets.systemBars.asPaddingValues()
|
||||
},
|
||||
)
|
||||
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) {
|
||||
0 -> BlendMode.SrcOver
|
||||
1 -> BlendMode.Multiply
|
||||
2 -> BlendMode.Screen
|
||||
3 -> BlendMode.Overlay
|
||||
4 -> BlendMode.Lighten
|
||||
5 -> BlendMode.Darken
|
||||
else -> unreachable()
|
||||
}
|
||||
}
|
||||
ReaderContentOverlay(
|
||||
brightness = { brightnessValue }.takeIf { brightness && brightnessValue < 0 },
|
||||
color = { colorOverlay }.takeIf { colorOverlayEnabled },
|
||||
colorBlendMode = colorOverlayMode,
|
||||
)
|
||||
val showPageNumber by Settings.showPageNumber.collectAsState()
|
||||
if (showPageNumber && !appbarVisible) {
|
||||
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) {
|
||||
PageIndicatorText(
|
||||
currentPage = syncState.sliderValue,
|
||||
totalPages = pageLoader.size,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).navigationBarsPadding(),
|
||||
)
|
||||
}
|
||||
}
|
||||
ReaderAppBars(
|
||||
visible = appbarVisible,
|
||||
isRtl = readingMode == ReadingModeType.RIGHT_TO_LEFT,
|
||||
showSeekBar = showSeekbar,
|
||||
currentPage = syncState.sliderValue,
|
||||
totalPages = pageLoader.size,
|
||||
onSliderValueChange = { syncState.sliderScrollTo(it + 1) },
|
||||
onClickSettings = {
|
||||
}
|
||||
VolumeKeysHandler(
|
||||
enabled = { volumeKeysEnabled && !appbarVisible },
|
||||
movePrevious = {
|
||||
if (isWebtoon) lazyListState.scrollUp() else pagerState.moveToPrevious()
|
||||
},
|
||||
moveNext = {
|
||||
if (isWebtoon) lazyListState.scrollDown() else pagerState.moveToNext()
|
||||
},
|
||||
)
|
||||
var showNavigationOverlay by remember {
|
||||
val showOnStart = Settings.showNavigationOverlayNewUser.value || Settings.showNavigationOverlayOnStart.value
|
||||
Settings.showNavigationOverlayNewUser.value = false
|
||||
mutableStateOf(showOnStart)
|
||||
}
|
||||
GalleryPager(
|
||||
type = readingMode,
|
||||
pagerState = pagerState,
|
||||
lazyListState = lazyListState,
|
||||
pageLoader = pageLoader,
|
||||
showNavigationOverlay = showNavigationOverlay,
|
||||
onNavigationModeChange = { showNavigationOverlay = true },
|
||||
onSelectPage = { page ->
|
||||
if (Settings.readerLongTapAction.value) {
|
||||
launch {
|
||||
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",
|
||||
)
|
||||
val state = rememberModalBottomSheetState()
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { dispose() },
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
|
||||
// Yeah, I know color state should not be read here, but we have to do it...
|
||||
scrimColor = scrim,
|
||||
dragHandle = null,
|
||||
sheetState = state,
|
||||
contentWindowInsets = { WindowInsets(0) },
|
||||
) {
|
||||
SettingsPager(modifier = Modifier.fillMaxSize()) { page ->
|
||||
isColorFilter = page == 2
|
||||
appbarVisible = !isColorFilter
|
||||
}
|
||||
ReaderPageSheetMeta(
|
||||
retry = { pageLoader.retryPage(page.index) },
|
||||
retryOrigin = { pageLoader.retryPage(page.index, true) },
|
||||
share = { launchIO { with(pageLoader) { shareImage(page, info) } } },
|
||||
copy = { launchIO { with(pageLoader) { copy(page) } } },
|
||||
save = { launchIO { with(pageLoader) { save(page) } } },
|
||||
saveTo = { launchIO { with(pageLoader) { saveTo(page) } } },
|
||||
dismiss = { launch { state.hide().also { dispose() } } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
onMenuRegionClick = { appbarVisible = !appbarVisible },
|
||||
modifier = Modifier.background(bgColor).pointerInput(syncState) {
|
||||
awaitEachGesture {
|
||||
waitForUpOrCancellation()
|
||||
syncState.reset()
|
||||
showNavigationOverlay = false
|
||||
}
|
||||
},
|
||||
contentPadding = if (fullscreen) {
|
||||
if (cutoutShort) {
|
||||
PaddingValues(0.dp)
|
||||
} else {
|
||||
WindowInsets.displayCutout.asPaddingValues()
|
||||
}
|
||||
} else {
|
||||
WindowInsets.systemBars.asPaddingValues()
|
||||
},
|
||||
)
|
||||
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) {
|
||||
0 -> BlendMode.SrcOver
|
||||
1 -> BlendMode.Multiply
|
||||
2 -> BlendMode.Screen
|
||||
3 -> BlendMode.Overlay
|
||||
4 -> BlendMode.Lighten
|
||||
5 -> BlendMode.Darken
|
||||
else -> unreachable()
|
||||
}
|
||||
}
|
||||
ReaderContentOverlay(
|
||||
brightness = { brightnessValue }.takeIf { brightness && brightnessValue < 0 },
|
||||
color = { colorOverlay }.takeIf { colorOverlayEnabled },
|
||||
colorBlendMode = colorOverlayMode,
|
||||
)
|
||||
if (brightness) {
|
||||
LaunchedEffect(Unit) {
|
||||
Settings.customBrightnessValue.valueFlow().sample(100)
|
||||
.onCompletion { setCustomBrightnessValue(0) }
|
||||
.collect { setCustomBrightnessValue(it) }
|
||||
}
|
||||
}
|
||||
val showPageNumber by Settings.showPageNumber.collectAsState()
|
||||
if (showPageNumber && !appbarVisible) {
|
||||
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) {
|
||||
PageIndicatorText(
|
||||
currentPage = syncState.sliderValue,
|
||||
totalPages = pageLoader.size,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).navigationBarsPadding(),
|
||||
)
|
||||
}
|
||||
}
|
||||
ReaderAppBars(
|
||||
visible = appbarVisible,
|
||||
isRtl = readingMode == ReadingModeType.RIGHT_TO_LEFT,
|
||||
showSeekBar = showSeekbar,
|
||||
currentPage = syncState.sliderValue,
|
||||
totalPages = pageLoader.size,
|
||||
onSliderValueChange = { syncState.sliderScrollTo(it + 1) },
|
||||
onClickSettings = {
|
||||
launch {
|
||||
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)),
|
||||
// Yeah, I know color state should not be read here, but we have to do it...
|
||||
scrimColor = scrim,
|
||||
dragHandle = null,
|
||||
contentWindowInsets = { WindowInsets(0) },
|
||||
) {
|
||||
SettingsPager(modifier = Modifier.fillMaxSize()) { page ->
|
||||
isColorFilter = page == 2
|
||||
appbarVisible = !isColorFilter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
context(Context, DialogState, DestinationsNavigator)
|
||||
private suspend fun preparePageLoader(args: ReaderScreenArgs) = when (args) {
|
||||
is ReaderScreenArgs.Gallery -> {
|
||||
val info = args.info
|
||||
val page = args.page
|
||||
val archive = withIOContext { DownloadManager.getDownloadInfo(info.gid)?.archiveFile }
|
||||
if (archive != null) {
|
||||
ArchivePageLoader(archive, info.gid, page)
|
||||
} else {
|
||||
EhPageLoader(info, page)
|
||||
}
|
||||
}
|
||||
is ReaderScreenArgs.Archive -> {
|
||||
ArchivePageLoader(args.uri.toOkioPath()) { invalidator ->
|
||||
awaitInputText(
|
||||
title = getString(R.string.archive_need_passwd),
|
||||
hint = getString(R.string.archive_passwd),
|
||||
onUserDismiss = { popBackStack() },
|
||||
) { text ->
|
||||
ensure(text.isNotBlank()) { getString(R.string.passwd_cannot_be_empty) }
|
||||
ensure(invalidator(text)) { getString(R.string.passwd_wrong) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -8,12 +7,11 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
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.runtime.snapshotFlow
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.gallery.PageLoader2
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
|
||||
@@ -23,8 +21,7 @@ class SliderPagerDoubleSync(
|
||||
private val pagerState: PagerState,
|
||||
private val pageLoader: PageLoader2,
|
||||
) {
|
||||
private var requestScroll = true
|
||||
private var sliderFollowPager = true
|
||||
private var sliderFollowPager by mutableStateOf(true)
|
||||
var sliderValue by mutableIntStateOf(pageLoader.startPage + 1)
|
||||
private set
|
||||
|
||||
@@ -33,14 +30,16 @@ class SliderPagerDoubleSync(
|
||||
sliderValue = index.coerceIn(1, pageLoader.size)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
sliderFollowPager = true
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Sync(webtoon: Boolean) {
|
||||
val fling by lazyListState.interactionSource.collectIsDraggedAsState()
|
||||
val pagerFling by pagerState.interactionSource.collectIsDraggedAsState()
|
||||
if (fling || pagerFling) sliderFollowPager = true
|
||||
fun Sync(webtoon: Boolean, onPageSelected: () -> Unit) {
|
||||
val currentIndexFlow = remember(webtoon) {
|
||||
requestScroll = true
|
||||
val initialIndex = sliderValue - 1
|
||||
if (webtoon) {
|
||||
sliderFollowPager = lazyListState.firstVisibleItemIndex == initialIndex
|
||||
snapshotFlow {
|
||||
with(lazyListState.layoutInfo) {
|
||||
visibleItemsInfo.lastOrNull {
|
||||
@@ -49,44 +48,28 @@ class SliderPagerDoubleSync(
|
||||
}
|
||||
}.filterNotNull()
|
||||
} else {
|
||||
sliderFollowPager = pagerState.currentPage == initialIndex
|
||||
snapshotFlow { pagerState.currentPage }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(currentIndexFlow) {
|
||||
currentIndexFlow.collect { index ->
|
||||
if (sliderFollowPager && !requestScroll) {
|
||||
if (sliderFollowPager) {
|
||||
LaunchedEffect(currentIndexFlow) {
|
||||
currentIndexFlow.collect { index ->
|
||||
sliderValue = index + 1
|
||||
pageLoader.startPage = index
|
||||
onPageSelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(webtoon) {
|
||||
snapshotFlow { sliderValue - 1 }.collectLatest { index ->
|
||||
if (sliderFollowPager && !requestScroll) return@collectLatest
|
||||
if (webtoon) {
|
||||
if (requestScroll) {
|
||||
lazyListState.requestScrollToItem(index)
|
||||
requestScroll = false
|
||||
} else {
|
||||
LaunchedEffect(webtoon) {
|
||||
snapshotFlow { sliderValue - 1 }.collectLatest { index ->
|
||||
if (webtoon) {
|
||||
lazyListState.scrollToItem(index)
|
||||
} else {
|
||||
if (smoothScroll((index - lazyListState.firstVisibleItemIndex))) {
|
||||
lazyListState.animateScrollToItem(index)
|
||||
} else {
|
||||
lazyListState.scrollToItem(index)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (requestScroll) {
|
||||
pagerState.requestScrollToPage(index)
|
||||
requestScroll = false
|
||||
} else {
|
||||
if (smoothScroll((index - pagerState.currentPage))) {
|
||||
pagerState.animateScrollToPage(index)
|
||||
} else {
|
||||
pagerState.scrollToPage(index)
|
||||
}
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
pageLoader.startPage = index
|
||||
}
|
||||
pageLoader.startPage = index
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,8 +84,3 @@ fun rememberSliderPagerDoubleSyncState(
|
||||
): SliderPagerDoubleSync = remember {
|
||||
SliderPagerDoubleSync(lazyListState, pagerState, pageLoader)
|
||||
}
|
||||
|
||||
private fun smoothScroll(distance: Int) =
|
||||
distance.absoluteValue < SMOOTH_SCROLL_THRESHOLD && Settings.pageTransitions.value
|
||||
|
||||
private const val SMOOTH_SCROLL_THRESHOLD = 50
|
||||
@@ -3,13 +3,23 @@ package com.hippo.ehviewer.ui.reader
|
||||
import android.app.Activity
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.AbsoluteAlignment
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.FixedScale
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
|
||||
private fun Window.updateKeepScreenOn(enabled: Boolean) {
|
||||
fun Window.updateKeepScreenOn(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
@@ -19,11 +29,98 @@ private fun Window.updateKeepScreenOn(enabled: Boolean) {
|
||||
|
||||
@Composable
|
||||
fun Activity.ConfigureKeepScreenOn() {
|
||||
val on by Settings.keepScreenOn.collectAsState()
|
||||
DisposableEffect(on) {
|
||||
window.updateKeepScreenOn(on)
|
||||
onDispose {
|
||||
window.updateKeepScreenOn(false)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
Settings.keepScreenOn.valueFlow()
|
||||
.onCompletion { window.updateKeepScreenOn(false) }
|
||||
.collect { window.updateKeepScreenOn(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the user preferred [orientation] on the activity.
|
||||
*/
|
||||
fun Activity.setOrientation(orientation: Int) {
|
||||
val newOrientation = OrientationType.fromPreference(orientation)
|
||||
if (newOrientation.flag != requestedOrientation) {
|
||||
requestedOrientation = newOrientation.flag
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the brightness of the screen. Range is [-75, 100].
|
||||
* From -75 to -1 a semi-transparent black view is overlaid with the minimum brightness.
|
||||
* From 1 to 100 it sets that value as brightness.
|
||||
* 0 sets system brightness and hides the overlay.
|
||||
*/
|
||||
fun Activity.setCustomBrightnessValue(value: Int) {
|
||||
val readerBrightness = when {
|
||||
value > 0 -> value / 100f
|
||||
value < 0 -> 0.01f
|
||||
else -> WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
|
||||
window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
|
||||
}
|
||||
|
||||
@Stable
|
||||
fun Alignment.Companion.fromPreferences(value: Int, isRtl: Boolean, isVertical: Boolean) = when (value) {
|
||||
1 -> when {
|
||||
isVertical -> CenterHorizontally
|
||||
isRtl -> AbsoluteAlignment.Right
|
||||
else -> AbsoluteAlignment.Left
|
||||
}
|
||||
2 -> AbsoluteAlignment.Left
|
||||
3 -> AbsoluteAlignment.Right
|
||||
else -> CenterHorizontally
|
||||
}
|
||||
|
||||
@Stable
|
||||
fun ContentScale.Companion.fromPreferences(value: Int, srcSize: Size, dstSize: Size) = when (value) {
|
||||
2 -> Crop
|
||||
3 -> FillWidth
|
||||
4 -> FillHeight
|
||||
5 -> FixedScale(1 / Inside.computeScaleFactor(srcSize, dstSize).scaleX)
|
||||
6 -> if (srcSize.width > srcSize.height) FillHeight else FillWidth
|
||||
else -> Fit
|
||||
}
|
||||
|
||||
suspend fun PagerState.performScrollToPage(page: Int) {
|
||||
if (Settings.pageTransitions.value) {
|
||||
animateScrollToPage(page)
|
||||
} else {
|
||||
scrollToPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun PagerState.moveToPrevious() {
|
||||
val target = currentPage - 1
|
||||
if (target >= 0) {
|
||||
performScrollToPage(target)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun PagerState.moveToNext() {
|
||||
val target = currentPage + 1
|
||||
if (target < pageCount) {
|
||||
performScrollToPage(target)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun LazyListState.performScrollBy(value: Float) {
|
||||
if (Settings.pageTransitions.value) {
|
||||
animateScrollBy(value)
|
||||
} else {
|
||||
scrollBy(value)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun LazyListState.scrollUp() {
|
||||
performScrollBy(-scrollDistance)
|
||||
}
|
||||
|
||||
suspend fun LazyListState.scrollDown() {
|
||||
performScrollBy(scrollDistance)
|
||||
}
|
||||
|
||||
private val LazyListState.scrollDistance
|
||||
get() = layoutInfo.viewportSize.height * 0.75f
|
||||
@@ -8,12 +8,15 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.ViewCompat
|
||||
import com.hippo.ehviewer.Settings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
context(CoroutineScope)
|
||||
@Composable
|
||||
fun VolumeKeysHandler(
|
||||
enabled: () -> Boolean,
|
||||
movePrevious: () -> Unit,
|
||||
moveNext: () -> Unit,
|
||||
movePrevious: suspend () -> Unit,
|
||||
moveNext: suspend () -> Unit,
|
||||
) {
|
||||
val view = LocalView.current
|
||||
DisposableEffect(view) {
|
||||
@@ -24,10 +27,12 @@ fun VolumeKeysHandler(
|
||||
val interval = Settings.readWithVolumeKeysInterval.value + 1
|
||||
val inverted = Settings.readWithVolumeKeysInverted.value
|
||||
if (event.action == ACTION_DOWN && event.repeatCount % interval == 0) {
|
||||
if (inverted.xor(keyCode == KEYCODE_VOLUME_UP)) {
|
||||
movePrevious()
|
||||
} else {
|
||||
moveNext()
|
||||
launch {
|
||||
if (inverted.xor(keyCode == KEYCODE_VOLUME_UP)) {
|
||||
movePrevious()
|
||||
} else {
|
||||
moveNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationRegions
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.getAction
|
||||
import kotlinx.coroutines.launch
|
||||
import me.saket.telephoto.zoomable.ZoomSpec
|
||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
import me.saket.telephoto.zoomable.zoomable
|
||||
|
||||
@Composable
|
||||
fun WebtoonViewer(
|
||||
lazyListState: LazyListState,
|
||||
withGaps: Boolean,
|
||||
pageLoader: PageLoader2,
|
||||
navigator: () -> NavigationRegions,
|
||||
onSelectPage: (ReaderPage) -> Unit,
|
||||
onMenuRegionClick: () -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val items = pageLoader.pages
|
||||
val zoomableState = rememberZoomableState(zoomSpec = WebtoonZoomSpec)
|
||||
val density = LocalDensity.current
|
||||
val paddingPercent by Settings.webtoonSidePadding.collectAsState()
|
||||
val sidePadding by remember(density) {
|
||||
snapshotFlow {
|
||||
with(density) {
|
||||
(lazyListState.layoutInfo.viewportSize.width * paddingPercent / 100f).toDp()
|
||||
}
|
||||
}
|
||||
}.collectAsState(0.dp)
|
||||
LazyColumn(
|
||||
modifier = modifier.zoomable(
|
||||
state = zoomableState,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
with(lazyListState) {
|
||||
val size = layoutInfo.viewportSize
|
||||
val x = it.x / size.width
|
||||
val y = it.y / size.height
|
||||
when (navigator().getAction(x, y)) {
|
||||
NavigationRegion.MENU -> onMenuRegionClick()
|
||||
NavigationRegion.NEXT, NavigationRegion.RIGHT -> scrollDown()
|
||||
NavigationRegion.PREV, NavigationRegion.LEFT -> scrollUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = { ofs ->
|
||||
val info = lazyListState.layoutInfo.visibleItemsInfo.find { info ->
|
||||
info.offset <= ofs.y && info.offset + info.size > ofs.y
|
||||
}
|
||||
if (info == null && withGaps) {
|
||||
// Maybe user long-click on gaps? ¯\_(ツ)_/¯
|
||||
return@zoomable
|
||||
}
|
||||
info ?: error("Internal error finding long click item!!! offset:$ofs")
|
||||
onSelectPage(items[info.index])
|
||||
},
|
||||
onDoubleClick = DoubleTapZoom,
|
||||
),
|
||||
state = lazyListState,
|
||||
contentPadding = contentPadding + PaddingValues(horizontal = sidePadding),
|
||||
verticalArrangement = Arrangement.spacedBy(if (withGaps) 15.dp else 0.dp),
|
||||
) {
|
||||
items(items, key = { it.index }) { page ->
|
||||
PagerItem(
|
||||
page = page,
|
||||
pageLoader = pageLoader,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val WebtoonZoomSpec = ZoomSpec(maxZoomFactor = 3f)
|
||||
@@ -4,6 +4,7 @@ import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -105,7 +106,7 @@ import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
|
||||
import com.hippo.ehviewer.ui.main.plus
|
||||
import com.hippo.ehviewer.ui.navToReader
|
||||
import com.hippo.ehviewer.ui.showMoveDownloadLabelList
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.Await
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalStaggeredGrid
|
||||
import com.hippo.ehviewer.ui.tools.HapticFeedbackType
|
||||
@@ -131,7 +132,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
var gridView by Settings.gridView.asMutableState()
|
||||
var sortMode by Settings.downloadSortMode.asMutableState()
|
||||
val filterMode by Settings.downloadFilterMode.collectAsState { DownloadsFilterMode.from(it) }
|
||||
@@ -351,7 +352,12 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
}
|
||||
}
|
||||
}
|
||||
ReorderableItem(reorderableLabelState, id, enabled = editEnable, animated = animateItems) { isDragging ->
|
||||
ReorderableItem(
|
||||
reorderableLabelState,
|
||||
id,
|
||||
enabled = editEnable,
|
||||
animateItemModifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
) { isDragging ->
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
backgroundContent = {},
|
||||
@@ -599,7 +605,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
}
|
||||
}
|
||||
|
||||
Deferred({ delay(200) }) {
|
||||
Await({ delay(200) }) {
|
||||
if (list.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(realPadding).fillMaxSize(),
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.hippo.ehviewer.ui.screen
|
||||
import android.content.Context
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.collection.MutableLongSet
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
@@ -94,7 +95,7 @@ import moe.tarsin.coroutines.runSwallowingWithUI
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
// Immutables
|
||||
val localFavName = stringResource(R.string.local_favorites)
|
||||
val cloudFavName = stringResource(R.string.cloud_favorites)
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.RelativeSizeSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.widget.TextView
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -14,6 +15,8 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -59,6 +62,7 @@ import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalTextToolbar
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -86,7 +90,6 @@ import com.hippo.ehviewer.ui.composing
|
||||
import com.hippo.ehviewer.ui.jumpToReaderByPage
|
||||
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
|
||||
import com.hippo.ehviewer.ui.main.GalleryCommentCard
|
||||
import com.hippo.ehviewer.ui.main.plus
|
||||
import com.hippo.ehviewer.ui.openBrowser
|
||||
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
|
||||
import com.hippo.ehviewer.ui.tools.normalizeSpan
|
||||
@@ -147,7 +150,7 @@ private val MinimumContentPaddingEditText = 88.dp
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fun AnimatedVisibilityScope.GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
var commenting by rememberSaveable { mutableStateOf(false) }
|
||||
val animationProgress by animateFloatMergePredictiveBackAsState(enable = commenting) { commenting = false }
|
||||
@@ -286,7 +289,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
isRefreshing = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize().imePadding(),
|
||||
modifier = Modifier.fillMaxSize().imePadding().padding(top = paddingValues.calculateTopPadding()),
|
||||
) {
|
||||
val additionalPadding = if (commenting) {
|
||||
editTextMeasured
|
||||
@@ -302,10 +305,15 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
val voteDownSucceed = stringResource(R.string.vote_down_successfully)
|
||||
val cancelVoteDownSucceed = stringResource(R.string.cancel_vote_down_successfully)
|
||||
val voteFailed = stringResource(R.string.vote_failed)
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(horizontal = keylineMargin),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = paddingValues + PaddingValues(bottom = additionalPadding),
|
||||
contentPadding = PaddingValues(
|
||||
start = paddingValues.calculateStartPadding(layoutDirection),
|
||||
end = paddingValues.calculateEndPadding(layoutDirection),
|
||||
bottom = paddingValues.calculateBottomPadding() + additionalPadding,
|
||||
),
|
||||
) {
|
||||
items(
|
||||
items = comments.comments,
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.os.Parcelable
|
||||
import android.text.TextUtils.TruncateAt.END
|
||||
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.foundation.MutatorMutex
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
@@ -212,7 +213,7 @@ private fun List<GalleryTagGroup>.getArtistTag(): String? {
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
var galleryInfo by remember {
|
||||
val casted = args as? GalleryInfoArgs
|
||||
mutableStateOf<GalleryInfo?>(casted?.galleryInfo)
|
||||
@@ -769,7 +770,11 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
DownloadInfo.STATE_FAILED -> stringResource(R.string.download_state_failed)
|
||||
else -> error("Invalid DownloadState!!!")
|
||||
}
|
||||
fun onReadButtonClick() = navToReader(galleryInfo.findBaseInfo(), startPage)
|
||||
fun onReadButtonClick() {
|
||||
if (galleryDetail != null || downloadState != DownloadInfo.STATE_INVALID) {
|
||||
navToReader(galleryInfo.findBaseInfo(), startPage)
|
||||
}
|
||||
}
|
||||
fun onCategoryChipClick() {
|
||||
val category = galleryInfo.category
|
||||
if (category == EhUtils.NONE || category == EhUtils.PRIVATE || category == EhUtils.UNKNOWN) {
|
||||
@@ -837,17 +842,19 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)),
|
||||
verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)),
|
||||
) {
|
||||
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
|
||||
GalleryDetailHeaderCard(
|
||||
info = galleryInfo,
|
||||
onInfoCardClick = ::onGalleryInfoCardClick,
|
||||
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
|
||||
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
|
||||
onCategoryChipClick = ::onCategoryChipClick,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = keylineMargin),
|
||||
)
|
||||
}
|
||||
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
|
||||
LocalPinnableContainer.current!!.run { remember { pin() } }
|
||||
Column {
|
||||
GalleryDetailHeaderCard(
|
||||
info = galleryInfo,
|
||||
onInfoCardClick = ::onGalleryInfoCardClick,
|
||||
onCategoryChipClick = ::onCategoryChipClick,
|
||||
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
|
||||
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = keylineMargin),
|
||||
)
|
||||
Row {
|
||||
FilledTonalButton(
|
||||
onClick = onDownloadButtonClick,
|
||||
@@ -889,39 +896,39 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)),
|
||||
) {
|
||||
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
|
||||
LocalPinnableContainer.current!!.run { remember { pin() } }
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
GalleryDetailHeaderCard(
|
||||
info = galleryInfo,
|
||||
onInfoCardClick = ::onGalleryInfoCardClick,
|
||||
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
|
||||
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
|
||||
onCategoryChipClick = ::onCategoryChipClick,
|
||||
modifier = Modifier.width(dimensionResource(id = R.dimen.gallery_detail_card_landscape_width)).padding(vertical = keylineMargin),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
GalleryDetailHeaderCard(
|
||||
info = galleryInfo,
|
||||
onInfoCardClick = ::onGalleryInfoCardClick,
|
||||
onCategoryChipClick = ::onCategoryChipClick,
|
||||
onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo),
|
||||
onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo),
|
||||
modifier = Modifier.width(dimensionResource(id = R.dimen.gallery_detail_card_landscape_width)).padding(vertical = keylineMargin),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
Spacer(modifier = modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = ::onReadButtonClick,
|
||||
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
|
||||
) {
|
||||
Spacer(modifier = modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = ::onReadButtonClick,
|
||||
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
|
||||
) {
|
||||
Text(text = readButtonText, maxLines = 1)
|
||||
}
|
||||
Spacer(modifier = modifier.height(24.dp))
|
||||
FilledTonalButton(
|
||||
onClick = onDownloadButtonClick,
|
||||
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
|
||||
) {
|
||||
Text(text = downloadButtonText, maxLines = 1)
|
||||
}
|
||||
Text(text = readButtonText, maxLines = 1)
|
||||
}
|
||||
Spacer(modifier = modifier.height(24.dp))
|
||||
FilledTonalButton(
|
||||
onClick = onDownloadButtonClick,
|
||||
modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp),
|
||||
) {
|
||||
Text(text = downloadButtonText, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item(span = { GridItemSpan(maxCurrentLineSpan) }) {
|
||||
LocalPinnableContainer.current!!.run { remember { pin() } }
|
||||
Column {
|
||||
if (getDetailError.isNotBlank()) {
|
||||
GalleryDetailErrorTip(error = getDetailError, onClick = { getDetailError = "" })
|
||||
} else if (galleryDetail != null) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.activity.compose.ReportDrawnWhen
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
@@ -124,7 +125,7 @@ import com.hippo.ehviewer.ui.main.GalleryInfoListItem
|
||||
import com.hippo.ehviewer.ui.main.GalleryList
|
||||
import com.hippo.ehviewer.ui.main.ImageSearch
|
||||
import com.hippo.ehviewer.ui.main.SearchFilter
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.Await
|
||||
import com.hippo.ehviewer.ui.tools.HapticFeedbackType
|
||||
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
|
||||
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
|
||||
@@ -133,6 +134,7 @@ import com.hippo.ehviewer.ui.tools.rememberHapticFeedback
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.ui.tools.rememberMutableStateInDataStore
|
||||
import com.hippo.ehviewer.ui.tools.snackBarPadding
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.pickVisualMedia
|
||||
import com.hippo.ehviewer.util.sha1
|
||||
@@ -155,23 +157,23 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun HomePageScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(), navigator)
|
||||
fun AnimatedVisibilityScope.HomePageScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(), navigator)
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun SubscriptionScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_SUBSCRIPTION), navigator)
|
||||
fun AnimatedVisibilityScope.SubscriptionScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_SUBSCRIPTION), navigator)
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun WhatshotScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_WHATS_HOT), navigator)
|
||||
fun AnimatedVisibilityScope.WhatshotScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_WHATS_HOT), navigator)
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun ToplistScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_TOPLIST, mKeyword = Settings.recentToplist), navigator)
|
||||
fun AnimatedVisibilityScope.ToplistScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_TOPLIST, mKeyword = Settings.recentToplist), navigator)
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fun AnimatedVisibilityScope.GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val searchFieldState = rememberTextFieldState()
|
||||
var urlBuilder by rememberSaveable(lub) { mutableStateOf(lub) }
|
||||
var searchBarExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -379,7 +381,11 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
) {
|
||||
itemsIndexed(quickSearchList, key = { _, item -> item.id!! }) { itemIndex, item ->
|
||||
val index by rememberUpdatedState(itemIndex)
|
||||
ReorderableItem(reorderableLazyListState, item.id!!, animated = animateItems) { isDragging ->
|
||||
ReorderableItem(
|
||||
reorderableLazyListState,
|
||||
item.id!!,
|
||||
animateItemModifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
) { isDragging ->
|
||||
// Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it
|
||||
val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density) }
|
||||
LaunchedEffect(dismissState) {
|
||||
@@ -467,7 +473,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
}
|
||||
}
|
||||
}
|
||||
Deferred({ delay(200) }) {
|
||||
Await({ delay(200) }) {
|
||||
if (quickSearchList.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.quick_search_tip),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.screen
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
@@ -57,7 +58,7 @@ import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun GalleryPreviewScreen(detail: GalleryDetail, toNextPage: Boolean, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fun AnimatedVisibilityScope.GalleryPreviewScreen(detail: GalleryDetail, toNextPage: Boolean, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val scrollBehaviour = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val pgSize = detail.previewList.size
|
||||
val state = rememberLazyGridState(initialFirstVisibleItemIndex = if (toNextPage) pgSize else 0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.screen
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -52,7 +53,7 @@ import com.hippo.ehviewer.ui.composing
|
||||
import com.hippo.ehviewer.ui.doGalleryInfoAction
|
||||
import com.hippo.ehviewer.ui.main.GalleryInfoListItem
|
||||
import com.hippo.ehviewer.ui.main.plus
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.Await
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
@@ -66,7 +67,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fun AnimatedVisibilityScope.HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val title = stringResource(id = R.string.history)
|
||||
val hint = stringResource(R.string.search_bar_hint, title)
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
@@ -164,7 +165,7 @@ fun HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Deferred(keyword, { delay(200) }) {
|
||||
Await(keyword, { delay(200) }) {
|
||||
if (historyData.itemCount == 0) {
|
||||
Column(
|
||||
modifier = Modifier.padding(paddingValues).padding(horizontal = marginH).fillMaxSize(),
|
||||
|
||||
@@ -38,7 +38,6 @@ import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SearchBar
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.SearchBarHorizontalPadding
|
||||
import androidx.compose.material3.SearchBarInputField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -314,3 +313,4 @@ fun wrapTagKeyword(keyword: String, translate: Boolean = false): String = if (ke
|
||||
}
|
||||
|
||||
private val WhitespaceRegex = Regex("\\s+")
|
||||
private val SearchBarHorizontalPadding = 16.dp
|
||||
@@ -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),
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -72,7 +73,7 @@ import splitties.init.appCtx
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun DownloadScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fun AnimatedVisibilityScope.DownloadScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
fun launchSnackBar(content: String) = launch { showSnackbar(content) }
|
||||
Scaffold(
|
||||
|
||||
@@ -333,11 +333,6 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
entryValueRes = R.array.thumb_resolution_entry_values,
|
||||
value = thumbResolution,
|
||||
)
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.settings_eh_force_eh_thumb),
|
||||
summary = stringResource(id = R.string.settings_eh_force_eh_thumb_summary),
|
||||
value = Settings::forceEhThumb,
|
||||
)
|
||||
var showGalleryPages by Settings.showGalleryPages.asMutableState()
|
||||
SwitchPref(
|
||||
checked = showGalleryPages,
|
||||
|
||||
@@ -58,7 +58,7 @@ import com.hippo.ehviewer.client.EhFilter.trigger
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.dao.Filter
|
||||
import com.hippo.ehviewer.dao.FilterMode
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.Await
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
@@ -213,7 +213,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
Deferred({ allFilterMap.await() }) { filters ->
|
||||
Await({ allFilterMap.await() }) { filters ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
contentPadding = paddingValues,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import arrow.core.None
|
||||
import arrow.core.Option
|
||||
import arrow.core.some
|
||||
|
||||
@Composable
|
||||
fun <T> Await(
|
||||
block: suspend () -> T,
|
||||
placeholder: (@Composable () -> Unit)? = null,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
var completed by remember(block) { mutableStateOf<Option<T>>(None) }
|
||||
LaunchedEffect(block) {
|
||||
completed = block().some()
|
||||
}
|
||||
if (placeholder != null) {
|
||||
completed.onNone { placeholder() }
|
||||
}
|
||||
completed.onSome { content(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> Await(key: Any?, block: suspend () -> T, content: @Composable (T) -> Unit) {
|
||||
var completed by remember(key, block) { mutableStateOf<Option<T>>(None) }
|
||||
LaunchedEffect(key, block) {
|
||||
completed = block().some()
|
||||
}
|
||||
completed.onSome { content(it) }
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
@Composable
|
||||
fun <T> Deferred(block: suspend () -> T, content: @Composable (T) -> Unit) {
|
||||
var completed by remember(block) { mutableStateOf<T?>(null) }
|
||||
LaunchedEffect(block) {
|
||||
completed = block()
|
||||
}
|
||||
completed?.let { content(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> Deferred(key: Any?, block: suspend () -> T, content: @Composable (T) -> Unit) {
|
||||
var completed by remember(key, block) { mutableStateOf<T?>(null) }
|
||||
LaunchedEffect(key, block) {
|
||||
completed = block()
|
||||
}
|
||||
completed?.let { content(it) }
|
||||
}
|
||||
@@ -50,12 +50,14 @@ import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -77,6 +79,7 @@ import com.jamal.composeprefs3.ui.ifNotNullThen
|
||||
import com.jamal.composeprefs3.ui.ifTrueThen
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
@@ -114,7 +117,6 @@ class DialogState {
|
||||
invalidator: (suspend Raise<String>.(R) -> Unit)? = null,
|
||||
block: @Composable DialogScope<R>.(String?) -> Unit,
|
||||
): R = dialog { cont ->
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val state = remember(cont) { mutableStateOf(initial) }
|
||||
var errorMsg by remember(cont) { mutableStateOf<String?>(null) }
|
||||
val impl = remember(cont) {
|
||||
@@ -122,17 +124,19 @@ class DialogState {
|
||||
override var expectedValue by state
|
||||
}
|
||||
}
|
||||
if (invalidator != null) {
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.value }.collectLatest {
|
||||
errorMsg = either { invalidator(it) }.leftOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { cont.cancel() },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
if (invalidator == null) {
|
||||
if (invalidator == null || errorMsg == null) {
|
||||
cont.resume(state.value)
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
errorMsg = either { invalidator(state.value) }.leftOrNull()
|
||||
errorMsg ?: cont.resume(state.value)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Text(text = stringResource(id = android.R.string.ok))
|
||||
@@ -156,13 +160,17 @@ class DialogState {
|
||||
hint: String? = null,
|
||||
isNumber: Boolean = false,
|
||||
@StringRes confirmText: Int = android.R.string.ok,
|
||||
onUserDismiss: (() -> Unit)? = null,
|
||||
invalidator: (suspend Raise<String>.(String) -> Unit)? = null,
|
||||
) = dialog { cont ->
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var state by remember(cont) { mutableStateOf(initial) }
|
||||
var error by remember(cont) { mutableStateOf<String?>(null) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { cont.cancel() },
|
||||
onDismissRequest = {
|
||||
cont.cancel()
|
||||
onUserDismiss?.invoke()
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
if (invalidator == null) {
|
||||
@@ -522,7 +530,7 @@ class DialogState {
|
||||
leadingContent = {
|
||||
Icon(imageVector = icon, contentDescription = null, tint = AlertDialogDefaults.iconContentColor)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Unspecified),
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -603,7 +611,7 @@ private fun CheckableItem(text: String, checked: Boolean, modifier: Modifier = M
|
||||
trailingContent = checked.ifTrueThen {
|
||||
Icon(imageVector = Icons.Default.Check, contentDescription = null, tint = checkedColor)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Unspecified),
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,41 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
inline fun Modifier.thenIf(condition: Boolean, crossinline block: Modifier.() -> Modifier) =
|
||||
if (condition) block() else this
|
||||
|
||||
val NoopTransitionsVisibilityScope = TransitionsVisibilityScope(emptySet())
|
||||
|
||||
@JvmInline
|
||||
@Stable
|
||||
value class TransitionsVisibilityScope(val scopes: Set<AnimatedVisibilityScope>)
|
||||
|
||||
context(TransitionsVisibilityScope)
|
||||
@Composable
|
||||
inline fun <T> togetherWith(scope: AnimatedVisibilityScope, block: @Composable TransitionsVisibilityScope.() -> T) =
|
||||
block(remember(scopes, scope) { TransitionsVisibilityScope(scopes + scope) })
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
@Composable
|
||||
fun Modifier.sharedBounds(
|
||||
key: String,
|
||||
enter: EnterTransition = fadeIn(),
|
||||
exit: ExitTransition = fadeOut(),
|
||||
) = scopes.fold(this) { modifier, scope ->
|
||||
modifier.sharedBounds(
|
||||
rememberSharedContentState("$key + ${scope.transition.label}"),
|
||||
scope,
|
||||
enter,
|
||||
exit,
|
||||
)
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.executeAndParseAs
|
||||
import com.hippo.ehviewer.client.executeSafely
|
||||
import com.hippo.ehviewer.util.copyTo
|
||||
import com.hippo.ehviewer.util.ensureSuccess
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.utils.io.jvm.javaio.toInputStream
|
||||
import java.io.File
|
||||
import java.util.zip.ZipInputStream
|
||||
@@ -17,10 +17,10 @@ import kotlin.time.Duration.Companion.days
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import org.json.JSONObject
|
||||
import tachiyomi.data.release.GithubArtifacts
|
||||
import tachiyomi.data.release.GithubCommitComparison
|
||||
import tachiyomi.data.release.GithubRelease
|
||||
import tachiyomi.data.release.GithubRepo
|
||||
import tachiyomi.data.release.GithubWorkflowRuns
|
||||
|
||||
private const val API_URL = "https://api.github.com/repos/${BuildConfig.REPO_NAME}"
|
||||
@@ -35,7 +35,7 @@ object AppUpdater {
|
||||
Settings.lastUpdateTime = now.epochSeconds
|
||||
if (Settings.useCIUpdateChannel) {
|
||||
val curSha = BuildConfig.COMMIT_SHA
|
||||
val branch = ghStatement(API_URL).executeSafely { JSONObject(it.bodyAsText()).getString("default_branch") }
|
||||
val branch = ghStatement(API_URL).executeAndParseAs<GithubRepo>().defaultBranch
|
||||
val workflowRunsUrl = "$API_URL/actions/workflows/ci.yml/runs?branch=$branch&event=push&status=success&per_page=1"
|
||||
val workflowRun = ghStatement(workflowRunsUrl).executeAndParseAs<GithubWorkflowRuns>().workflowRuns[0]
|
||||
val shortSha = workflowRun.headSha.take(7)
|
||||
@@ -68,6 +68,7 @@ object AppUpdater {
|
||||
|
||||
suspend fun downloadUpdate(url: String, file: File) =
|
||||
ghStatement(url).executeSafely { response ->
|
||||
response.status.ensureSuccess()
|
||||
if (url.endsWith("zip")) {
|
||||
response.bodyAsChannel().toInputStream().use { stream ->
|
||||
ZipInputStream(stream).use { zip ->
|
||||
@@ -84,9 +85,16 @@ object AppUpdater {
|
||||
}
|
||||
|
||||
private suspend inline fun ghStatement(url: String) = ktorClient.prepareGet(url) {
|
||||
header("Authorization", "Bearer ${BuildConfig.GITHUB_TOKEN}")
|
||||
header("Authorization", GithubTokenParts.joinToString("_", prefix = "Bearer "))
|
||||
}
|
||||
|
||||
private val GithubTokenParts = arrayOf(
|
||||
"github",
|
||||
"pat",
|
||||
"11A4H2ACI0XXTRsYkt1Mps",
|
||||
"uJ5n8UbLv20j6Ue4k1x25Z7u63EaCdDNYooxSeI4GJaQYKXWDZEtTmDVwnh",
|
||||
)
|
||||
|
||||
data class Release(
|
||||
val version: String,
|
||||
val changelog: String,
|
||||
|
||||
@@ -39,12 +39,6 @@ private fun getReadableStringInternal(e: Throwable) = when (e) {
|
||||
is MalformedURLException -> appCtx.getString(R.string.error_invalid_url)
|
||||
is SocketTimeoutException -> appCtx.getString(R.string.error_timeout)
|
||||
is UnknownHostException -> appCtx.getString(R.string.error_unknown_host)
|
||||
is StatusCodeException -> buildString {
|
||||
append(appCtx.getString(R.string.error_bad_status_code, e.responseCode))
|
||||
if (e.isIdentifiedResponseCode) {
|
||||
append(", ").append(e.message)
|
||||
}
|
||||
}
|
||||
is ProtocolException -> if (e.message!!.startsWith("Too many follow-up requests:")) {
|
||||
appCtx.getString(R.string.error_redirection)
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.hippo.ehviewer.util
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.utils.io.charsets.decode
|
||||
import io.ktor.utils.io.core.ByteReadPacket
|
||||
import java.nio.charset.CodingErrorAction
|
||||
@@ -15,3 +17,7 @@ suspend fun HttpResponse.bodyAsUtf8Text(): String {
|
||||
|
||||
return decoder.decode(input)
|
||||
}
|
||||
|
||||
fun HttpStatusCode.ensureSuccess() {
|
||||
if (!isSuccess()) throw Exception(toString())
|
||||
}
|
||||
@@ -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,62 +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 com.hippo.ehviewer.util
|
||||
|
||||
import android.util.SparseArray
|
||||
import com.hippo.ehviewer.R
|
||||
import splitties.init.appCtx
|
||||
|
||||
class StatusCodeException(val responseCode: Int) : Exception() {
|
||||
override val message: String = ERROR_MESSAGE_ARRAY[responseCode, DEFAULT_ERROR_MESSAGE]
|
||||
|
||||
val isIdentifiedResponseCode: Boolean
|
||||
get() = DEFAULT_ERROR_MESSAGE != message
|
||||
|
||||
override fun getLocalizedMessage(): String = message
|
||||
|
||||
companion object {
|
||||
private val ERROR_MESSAGE_ARRAY = SparseArray<String>(24)
|
||||
private const val DEFAULT_ERROR_MESSAGE = "Error response code"
|
||||
|
||||
init {
|
||||
val resources = appCtx.resources
|
||||
ERROR_MESSAGE_ARRAY.append(400, resources.getString(R.string.error_status_code_400))
|
||||
ERROR_MESSAGE_ARRAY.append(401, resources.getString(R.string.error_status_code_401))
|
||||
ERROR_MESSAGE_ARRAY.append(402, resources.getString(R.string.error_status_code_402))
|
||||
ERROR_MESSAGE_ARRAY.append(403, resources.getString(R.string.error_status_code_403))
|
||||
ERROR_MESSAGE_ARRAY.append(404, resources.getString(R.string.error_status_code_404))
|
||||
ERROR_MESSAGE_ARRAY.append(405, resources.getString(R.string.error_status_code_405))
|
||||
ERROR_MESSAGE_ARRAY.append(406, resources.getString(R.string.error_status_code_406))
|
||||
ERROR_MESSAGE_ARRAY.append(407, resources.getString(R.string.error_status_code_407))
|
||||
ERROR_MESSAGE_ARRAY.append(408, resources.getString(R.string.error_status_code_408))
|
||||
ERROR_MESSAGE_ARRAY.append(409, resources.getString(R.string.error_status_code_409))
|
||||
ERROR_MESSAGE_ARRAY.append(410, resources.getString(R.string.error_status_code_410))
|
||||
ERROR_MESSAGE_ARRAY.append(411, resources.getString(R.string.error_status_code_411))
|
||||
ERROR_MESSAGE_ARRAY.append(412, resources.getString(R.string.error_status_code_412))
|
||||
ERROR_MESSAGE_ARRAY.append(413, resources.getString(R.string.error_status_code_413))
|
||||
ERROR_MESSAGE_ARRAY.append(414, resources.getString(R.string.error_status_code_414))
|
||||
ERROR_MESSAGE_ARRAY.append(415, resources.getString(R.string.error_status_code_415))
|
||||
ERROR_MESSAGE_ARRAY.append(416, resources.getString(R.string.error_status_code_416))
|
||||
ERROR_MESSAGE_ARRAY.append(417, resources.getString(R.string.error_status_code_417))
|
||||
ERROR_MESSAGE_ARRAY.append(500, resources.getString(R.string.error_status_code_500))
|
||||
ERROR_MESSAGE_ARRAY.append(501, resources.getString(R.string.error_status_code_501))
|
||||
ERROR_MESSAGE_ARRAY.append(502, resources.getString(R.string.error_status_code_502))
|
||||
ERROR_MESSAGE_ARRAY.append(503, resources.getString(R.string.error_status_code_503))
|
||||
ERROR_MESSAGE_ARRAY.append(504, resources.getString(R.string.error_status_code_504))
|
||||
ERROR_MESSAGE_ARRAY.append(505, resources.getString(R.string.error_status_code_505))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,833 +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.view.WindowManager
|
||||
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.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.OrientationType
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the user preferred [orientation] on the activity.
|
||||
*/
|
||||
private fun setOrientation(orientation: Int) {
|
||||
val newOrientation = OrientationType.fromPreference(orientation)
|
||||
if (newOrientation.flag != requestedOrientation) {
|
||||
requestedOrientation = newOrientation.flag
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { setKeepScreenOn(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 keep screen on mode according to [enabled].
|
||||
*/
|
||||
private fun setKeepScreenOn(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the brightness of the screen. Range is [-75, 100].
|
||||
* From -75 to -1 a semi-transparent black view is overlaid with the minimum brightness.
|
||||
* From 1 to 100 it sets that value as brightness.
|
||||
* 0 sets system brightness and hides the overlay.
|
||||
*/
|
||||
private fun setCustomBrightnessValue(value: Int) {
|
||||
// Calculate and set reader brightness.
|
||||
val readerBrightness = when {
|
||||
value > 0 -> {
|
||||
value / 100f
|
||||
}
|
||||
|
||||
value < 0 -> {
|
||||
0.01f
|
||||
}
|
||||
|
||||
else -> WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
|
||||
window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -2,17 +2,18 @@ package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.collection.lruCache
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.image.Image
|
||||
import com.hippo.ehviewer.util.OSUtils
|
||||
import com.hippo.ehviewer.util.isAtLeastO
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
|
||||
private const val MAX_CACHE_SIZE = 512 * 1024 * 1024
|
||||
private const val MIN_CACHE_SIZE = 128 * 1024 * 1024
|
||||
|
||||
abstract class PageLoader {
|
||||
|
||||
private val cache by lazy {
|
||||
lruCache<Int, Image>(
|
||||
maxSize = if (isAtLeastO) {
|
||||
@@ -22,25 +23,33 @@ abstract class PageLoader {
|
||||
},
|
||||
sizeOf = { _, v -> v.size.toInt() },
|
||||
onEntryRemoved = { _, k, o, n ->
|
||||
n ?: notifyPageWait(k)
|
||||
o.recycle()
|
||||
if (o.isRecyclable) {
|
||||
n ?: notifyPageWait(k)
|
||||
o.recycle()
|
||||
} else {
|
||||
o.isRecyclable = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val pages by lazy {
|
||||
check(size > 0)
|
||||
(0 until size).map { ReaderPage(it) }
|
||||
}
|
||||
|
||||
private val preloads = com.hippo.ehviewer.Settings.preloadImage.coerceIn(0, 100)
|
||||
private val prefetchPageCount = Settings.preloadImage
|
||||
|
||||
@CallSuper
|
||||
open suspend fun awaitReady(): Boolean {
|
||||
withIOContext { cache }
|
||||
return true
|
||||
}
|
||||
|
||||
abstract suspend fun awaitReady(): Boolean
|
||||
abstract val isReady: Boolean
|
||||
|
||||
@CallSuper
|
||||
open fun start() {
|
||||
cache
|
||||
}
|
||||
abstract fun start()
|
||||
|
||||
@CallSuper
|
||||
open fun stop() {
|
||||
@@ -53,20 +62,35 @@ abstract class PageLoader {
|
||||
|
||||
abstract val size: Int
|
||||
|
||||
private var lastRequestIndex = -1
|
||||
|
||||
fun request(index: Int) {
|
||||
val image = cache[index]
|
||||
if (image != null) {
|
||||
notifyPageSucceed(index, image)
|
||||
notifyPageSucceed(index, image, false)
|
||||
} else {
|
||||
notifyPageWait(index)
|
||||
onRequest(index)
|
||||
}
|
||||
|
||||
val pagesAbsent = ((index - 5).coerceAtLeast(0) until (preloads + index).coerceAtMost(size))
|
||||
.mapNotNullTo(mutableListOf()) { it.takeIf { it != index && cache[it] == null } }
|
||||
// Load forward first, then load backward from the nearest index
|
||||
pagesAbsent.sortBy { (index - it).coerceAtLeast(0) }
|
||||
preloadPages(pagesAbsent, (index - 10).coerceAtLeast(0) to (preloads + index + 10).coerceAtMost(size))
|
||||
// Prefetch to disk
|
||||
val prefetchRange = if (index >= lastRequestIndex) {
|
||||
index + 1..(index + prefetchPageCount).coerceAtMost(size - 1)
|
||||
} else {
|
||||
index - 1 downTo (index - prefetchPageCount).coerceAtLeast(0)
|
||||
}
|
||||
val pagesAbsent = prefetchRange.filter { pages[it].status.value == Page.State.QUEUE }
|
||||
val start = if (prefetchRange.step > 0) prefetchRange.first else prefetchRange.last
|
||||
val end = if (prefetchRange.step > 0) prefetchRange.last else prefetchRange.first
|
||||
prefetchPages(pagesAbsent, start - 5 to end + 5)
|
||||
|
||||
// Prefetch to memory
|
||||
val range = index - 3..index + 3
|
||||
pagesAbsent.forEach {
|
||||
if (it in range) onRequest(it)
|
||||
}
|
||||
|
||||
lastRequestIndex = index
|
||||
}
|
||||
|
||||
fun retryPage(index: Int, orgImg: Boolean = false) {
|
||||
@@ -74,7 +98,7 @@ abstract class PageLoader {
|
||||
onForceRequest(index, orgImg)
|
||||
}
|
||||
|
||||
protected abstract fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>)
|
||||
protected abstract fun prefetchPages(pages: List<Int>, bounds: Pair<Int, Int>)
|
||||
|
||||
protected abstract fun onRequest(index: Int)
|
||||
|
||||
@@ -87,16 +111,17 @@ abstract class PageLoader {
|
||||
protected abstract fun onCancelRequest(index: Int)
|
||||
|
||||
fun notifyPageWait(index: Int) {
|
||||
pages[index].progress = 0
|
||||
pages[index].status.value = Page.State.QUEUE
|
||||
}
|
||||
|
||||
fun notifyPagePercent(index: Int, percent: Float) {
|
||||
pages[index].status.compareAndSet(Page.State.QUEUE, Page.State.DOWNLOAD_IMAGE)
|
||||
pages[index].progress = (percent * 100).toInt()
|
||||
pages[index].status.compareAndSet(Page.State.QUEUE, Page.State.DOWNLOAD_IMAGE)
|
||||
}
|
||||
|
||||
fun notifyPageSucceed(index: Int, image: Image) {
|
||||
if (cache[index] != image) {
|
||||
fun notifyPageSucceed(index: Int, image: Image, replaceCache: Boolean = true) {
|
||||
if (replaceCache) {
|
||||
cache.put(index, image)
|
||||
}
|
||||
pages[index].image = image
|
||||
|
||||
@@ -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(
|
||||
@@ -37,6 +33,11 @@ enum class ReadingModeType(
|
||||
WEBTOON, CONTINUOUS_VERTICAL -> true
|
||||
}
|
||||
|
||||
fun isVertical(type: ReadingModeType) = when (type) {
|
||||
DEFAULT, LEFT_TO_RIGHT, RIGHT_TO_LEFT -> false
|
||||
VERTICAL, WEBTOON, CONTINUOUS_VERTICAL -> true
|
||||
}
|
||||
|
||||
const val MASK = 0x00000007
|
||||
|
||||
fun fromPreference(preference: Int?): ReadingModeType = entries.find { it.flagValue == preference } ?: DEFAULT
|
||||
@@ -47,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,14 @@
|
||||
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
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation
|
||||
import eu.kanade.tachiyomi.util.lang.invert
|
||||
|
||||
abstract class ViewerNavigation {
|
||||
@@ -21,30 +25,33 @@ 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
|
||||
var invertMode = 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
|
||||
companion object {
|
||||
fun fromPreference(value: Int, isVertical: Boolean) = when (value) {
|
||||
1 -> LNavigation()
|
||||
2 -> KindlishNavigation()
|
||||
3 -> EdgeNavigation()
|
||||
4 -> RightAndLeftNavigation()
|
||||
5 -> DisabledNavigation()
|
||||
else -> if (isVertical) LNavigation() else RightAndLeftNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias NavigationRegions = List<ViewerNavigation.Region>
|
||||
|
||||
fun NavigationRegions.getAction(x: Float, y: Float) =
|
||||
find { it.rectF.contains(x, y) }?.type ?: ViewerNavigation.NavigationRegion.MENU
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user