mirror of
https://github.com/mihonapp/mihon.git
synced 2025-06-26 19:17:51 +02:00
Upstream merge
This commit is contained in:
2
.github/ISSUE_TEMPLATE.md
vendored
2
.github/ISSUE_TEMPLATE.md
vendored
@ -4,6 +4,8 @@
|
||||
|
||||
**App version:**
|
||||
|
||||
**Android version:**
|
||||
|
||||
**Issue/Request:**
|
||||
|
||||
**Steps to reproduce (if applicable)**
|
||||
|
101
README.md
Executable file → Normal file
101
README.md
Executable file → Normal file
@ -1,55 +1,70 @@
|
||||
<div style="text-align:center"><img src ="https://raw.githubusercontent.com/NerdNumber9/TachiyomiEH/master/branding/teh-banner.png" /></div>
|
||||
<br>
|
||||
| Build | Stable | Dev | Contribute | Contact |
|
||||
|-------|----------|---------|------------|---------|
|
||||
| [](https://travis-ci.org/inorichi/tachiyomi) | [)](https://github.com/inorichi/tachiyomi/releases) | [](http://tachiyomi.kanade.eu/latest) | [](https://hosted.weblate.org/engage/tachiyomi/?utm_source=widget) | [](https://discord.gg/tachiyomi) |
|
||||
|
||||
TachiyomiEH is a free and open source E-Hentai, ExHentai and PervEden galleries reader for Android.
|
||||
|
||||
TachiyomiEH is a fork of the [original Tachiyomi app](https://github.com/inorichi/tachiyomi).
|
||||
### E-Hentai Thread
|
||||
[https://forums.e-hentai.org/index.php?showtopic=185421](https://forums.e-hentai.org/index.php?showtopic=185421)
|
||||
# Tachiyomi
|
||||
Tachiyomi is a free and open source manga reader for Android.
|
||||
|
||||
# Download
|
||||
[](https://github.com/NerdNumber9/TachiyomiEH/releases)
|
||||

|
||||
|
||||
# Features
|
||||
## Features
|
||||
|
||||
* Online and offline reading
|
||||
* Configurable reader with multiple viewers and settings
|
||||
* MyAnimeList support
|
||||
* Track your reading position
|
||||
* Chapter filtering
|
||||
* Schedule searching for updates
|
||||
Features include:
|
||||
* Online reading from sources such as KissManga, MangaFox, [and more](https://github.com/inorichi/tachiyomi-extensions)
|
||||
* Local reading of downloaded manga
|
||||
* Configurable reader with multiple viewers, reading directions and other settings
|
||||
* [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), and [Kitsu](https://kitsu.io/explore/anime) support
|
||||
* Categories to organize your library
|
||||
* Log into ExHentai
|
||||
* Read both NSFW and SFW manga/doujinshi
|
||||
* Full offline tag/namespace searching support
|
||||
* Batch import galleries
|
||||
* Automatically open E-Hentai/ExHentai links
|
||||
* Lock the app with a PIN code
|
||||
* Light and dark themes
|
||||
* Schedule updating your library for new chapters
|
||||
* Create backups locally to read offline or to your desired cloud service
|
||||
|
||||
### Built-in manga sources
|
||||
##### SFW
|
||||
* Batoto
|
||||
* Mangahere
|
||||
* Mangafox
|
||||
* Kissmanga
|
||||
* Readmanga
|
||||
* Mintmanga
|
||||
* Mangachan
|
||||
* Readmangatoday
|
||||
* Mangasee
|
||||
* Wiemanga
|
||||
* And more!
|
||||
## Download
|
||||
Get the app from our [releases page](https://github.com/inorichi/tachiyomi/releases).
|
||||
|
||||
##### NSFW
|
||||
* E-Hentai
|
||||
* ExHentai
|
||||
* PervEden
|
||||
* nhentai
|
||||
* Tsumino
|
||||
* Hitomi.la
|
||||
If you want to try new features before they get to the stable release, you can download the dev version [here](http://tachiyomi.kanade.eu/latest). (auto-updates not included)
|
||||
|
||||
TachiyomiEH is fully compatible with Tachiyomi source extensions.
|
||||
Backups from Tachiyomi are also compatible with TachiyomiEH (and vice versa).
|
||||
## Issues, Feature Requests and Contributing
|
||||
|
||||
Please make sure to read the full guidelines. Your issue may be closed without warning if you do not.
|
||||
|
||||
<details><summary>Issues</summary>
|
||||
|
||||
1. **Before reporting a new issue, take a look at the [FAQ](https://github.com/inorichi/tachiyomi/wiki/FAQ), the [changelog](https://github.com/inorichi/tachiyomi/releases) and the already opened [issues](https://github.com/inorichi/tachiyomi/issues).**
|
||||
2. If you are unsure, ask here: [](https://discord.gg/tachiyomi)
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Bugs</summary>
|
||||
|
||||
* Include version (Setting > About > Version)
|
||||
* If not latest, try updating, it may have already been solved
|
||||
* Dev version is equal to the number of commits as seen in the main page
|
||||
* Include steps to reproduce (if not obvious from description)
|
||||
* Include screenshot (if needed)
|
||||
* If it could be device-dependent, try reproducing on another device (if possible)
|
||||
* For large logs use http://pastebin.com/ (or similar)
|
||||
* Don't group unrelated requests into one issue
|
||||
|
||||
DO: https://github.com/inorichi/tachiyomi/issues/24 https://github.com/inorichi/tachiyomi/issues/71
|
||||
|
||||
DON'T: https://github.com/inorichi/tachiyomi/issues/75
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Feature Requests</summary>
|
||||
|
||||
* Write a detailed issue, explaning what it should do or how. Avoid writing just "like X app does"
|
||||
* Include screenshot (if needed)
|
||||
|
||||
Catalogue requests should be created at https://github.com/inorichi/tachiyomi-extensions, they do not belong in this repository.
|
||||
</details>
|
||||
|
||||
## FAQ
|
||||
|
||||
[See our wiki.](https://github.com/inorichi/tachiyomi/wiki/FAQ)
|
||||
You can also reach out to us on [Discord](https://discord.gg/tachiyomi).
|
||||
|
||||
## License
|
||||
|
||||
|
@ -36,7 +36,7 @@ ext {
|
||||
|
||||
android {
|
||||
compileSdkVersion 27
|
||||
buildToolsVersion '27.0.3'
|
||||
buildToolsVersion '28.0.3'
|
||||
publishNonDefault true
|
||||
|
||||
defaultConfig {
|
||||
@ -44,8 +44,8 @@ android {
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 27
|
||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||
versionCode 7404
|
||||
versionName "v7.4.4-EH"
|
||||
versionCode 8200
|
||||
versionName "v8.2.0-EH"
|
||||
|
||||
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
|
||||
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
|
||||
@ -111,7 +111,7 @@ android {
|
||||
dependencies {
|
||||
|
||||
// Modified dependencies
|
||||
implementation 'com.github.inorichi:subsampling-scale-image-view:81b9d68'
|
||||
implementation 'com.github.inorichi:subsampling-scale-image-view:ac0dae7'
|
||||
implementation 'com.github.inorichi:junrar-android:634c1f5'
|
||||
|
||||
// Android support library
|
||||
@ -170,7 +170,10 @@ dependencies {
|
||||
implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
|
||||
|
||||
// Database
|
||||
implementation "com.pushtorefresh.storio:sqlite:1.13.0"
|
||||
implementation 'android.arch.persistence:db:1.0.0'
|
||||
implementation 'com.github.inorichi.storio:storio-common:8be19de@aar'
|
||||
implementation 'com.github.inorichi.storio:storio-sqlite:8be19de@aar'
|
||||
implementation 'io.requery:sqlite-android:3.25.2'
|
||||
|
||||
// Model View Presenter
|
||||
final nucleus_version = '3.0.0'
|
||||
@ -210,11 +213,12 @@ dependencies {
|
||||
implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4'
|
||||
implementation 'com.github.mthli:Slice:v1.2'
|
||||
implementation 'me.gujun.android.taggroup:library:1.4@aar'
|
||||
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
|
||||
implementation 'com.github.inorichi:DirectionalViewPager:3acc51a'
|
||||
|
||||
// Conductor
|
||||
implementation "com.github.inorichi.Conductor:conductor:be8b3c5"
|
||||
implementation("com.bluelinelabs:conductor-support:2.1.5-SNAPSHOT") {
|
||||
exclude group: "com.bluelinelabs", module: "conductor"
|
||||
implementation 'com.bluelinelabs:conductor:2.1.5'
|
||||
implementation("com.bluelinelabs:conductor-support:2.1.5") {
|
||||
exclude group: "com.android.support"
|
||||
}
|
||||
implementation 'com.github.inorichi:conductor-support-preference:27.0.2'
|
||||
@ -264,7 +268,7 @@ dependencies {
|
||||
}
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.2.60'
|
||||
ext.kotlin_version = '1.2.71'
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
@ -43,8 +43,7 @@
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.reader.ReaderActivity"
|
||||
android:theme="@style/Theme.Reader" />
|
||||
android:name=".ui.reader.ReaderActivity" />
|
||||
<activity
|
||||
android:name=".widget.CustomLayoutPickerActivity"
|
||||
android:label="@string/app_name"
|
||||
@ -76,14 +75,6 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
<provider
|
||||
android:name=".util.ZipContentProvider"
|
||||
android:authorities="${applicationId}.zip-provider"
|
||||
android:exported="false" />
|
||||
<provider
|
||||
android:name=".util.RarContentProvider"
|
||||
android:authorities="${applicationId}.rar-provider"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".data.notification.NotificationReceiver"
|
||||
|
@ -50,13 +50,17 @@ open class App : Application() {
|
||||
}
|
||||
|
||||
protected open fun setupJobManager() {
|
||||
JobManager.create(this).addJobCreator { tag ->
|
||||
when (tag) {
|
||||
LibraryUpdateJob.TAG -> LibraryUpdateJob()
|
||||
UpdaterJob.TAG -> UpdaterJob()
|
||||
BackupCreatorJob.TAG -> BackupCreatorJob()
|
||||
else -> null
|
||||
try {
|
||||
JobManager.create(this).addJobCreator { tag ->
|
||||
when (tag) {
|
||||
LibraryUpdateJob.TAG -> LibraryUpdateJob()
|
||||
UpdaterJob.TAG -> UpdaterJob()
|
||||
BackupCreatorJob.TAG -> BackupCreatorJob()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w("Can't initialize job manager")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,12 @@
|
||||
package eu.kanade.tachiyomi.data.database
|
||||
|
||||
import android.arch.persistence.db.SupportSQLiteOpenHelper
|
||||
import android.content.Context
|
||||
import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite
|
||||
import eu.kanade.tachiyomi.data.database.mappers.*
|
||||
import eu.kanade.tachiyomi.data.database.models.*
|
||||
import eu.kanade.tachiyomi.data.database.queries.*
|
||||
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
|
||||
|
||||
/**
|
||||
* This class provides operations to manage the database through its interfaces.
|
||||
@ -12,8 +14,13 @@ import eu.kanade.tachiyomi.data.database.queries.*
|
||||
open class DatabaseHelper(context: Context)
|
||||
: MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
|
||||
|
||||
private val configuration = SupportSQLiteOpenHelper.Configuration.builder(context)
|
||||
.name(DbOpenCallback.DATABASE_NAME)
|
||||
.callback(DbOpenCallback())
|
||||
.build()
|
||||
|
||||
override val db = DefaultStorIOSQLite.builder()
|
||||
.sqliteOpenHelper(DbOpenHelper(context))
|
||||
.sqliteOpenHelper(RequerySQLiteOpenHelperFactory().create(configuration))
|
||||
.addTypeMapping(Manga::class.java, MangaTypeMapping())
|
||||
.addTypeMapping(Chapter::class.java, ChapterTypeMapping())
|
||||
.addTypeMapping(Track::class.java, TrackTypeMapping())
|
||||
|
@ -1,12 +1,13 @@
|
||||
package eu.kanade.tachiyomi.data.database
|
||||
|
||||
import android.arch.persistence.db.SupportSQLiteDatabase
|
||||
import android.arch.persistence.db.SupportSQLiteOpenHelper
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import eu.kanade.tachiyomi.data.database.tables.*
|
||||
|
||||
class DbOpenHelper(context: Context)
|
||||
: SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
|
||||
class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
@ -17,10 +18,10 @@ class DbOpenHelper(context: Context)
|
||||
/**
|
||||
* Version of the database.
|
||||
*/
|
||||
const val DATABASE_VERSION = 7
|
||||
const val DATABASE_VERSION = 8
|
||||
}
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) = with(db) {
|
||||
override fun onCreate(db: SupportSQLiteDatabase) = with(db) {
|
||||
execSQL(MangaTable.createTableQuery)
|
||||
execSQL(ChapterTable.createTableQuery)
|
||||
execSQL(TrackTable.createTableQuery)
|
||||
@ -30,12 +31,13 @@ class DbOpenHelper(context: Context)
|
||||
|
||||
// DB indexes
|
||||
execSQL(MangaTable.createUrlIndexQuery)
|
||||
execSQL(MangaTable.createFavoriteIndexQuery)
|
||||
execSQL(MangaTable.createLibraryIndexQuery)
|
||||
execSQL(ChapterTable.createMangaIdIndexQuery)
|
||||
execSQL(ChapterTable.createUnreadChaptersIndexQuery)
|
||||
execSQL(HistoryTable.createChapterIdIndexQuery)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
if (oldVersion < 2) {
|
||||
db.execSQL(ChapterTable.sourceOrderUpdateQuery)
|
||||
|
||||
@ -60,9 +62,14 @@ class DbOpenHelper(context: Context)
|
||||
if (oldVersion < 7) {
|
||||
db.execSQL(TrackTable.addLibraryId)
|
||||
}
|
||||
if (oldVersion < 8) {
|
||||
db.execSQL("DROP INDEX IF EXISTS mangas_favorite_index")
|
||||
db.execSQL(MangaTable.createLibraryIndexQuery)
|
||||
db.execSQL(ChapterTable.createUnreadChaptersIndexQuery)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConfigure(db: SQLiteDatabase) {
|
||||
override fun onConfigure(db: SupportSQLiteDatabase) {
|
||||
db.setForeignKeyConstraintsEnabled(true)
|
||||
}
|
||||
|
@ -6,10 +6,7 @@ import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.models.LibraryManga
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaFavoritePutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.*
|
||||
import eu.kanade.tachiyomi.data.database.tables.CategoryTable
|
||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
|
||||
@ -80,6 +77,11 @@ interface MangaQueries : DbProvider {
|
||||
.withPutResolver(MangaFavoritePutResolver())
|
||||
.prepare()
|
||||
|
||||
fun updateMangaViewer(manga: Manga) = db.put()
|
||||
.`object`(manga)
|
||||
.withPutResolver(MangaViewerPutResolver())
|
||||
.prepare()
|
||||
|
||||
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
|
||||
|
||||
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()
|
||||
@ -108,4 +110,4 @@ interface MangaQueries : DbProvider {
|
||||
|
||||
fun getTotalChapterManga() = db.get().listOfObjects(Manga::class.java)
|
||||
.withQuery(RawQuery.builder().query(getTotalChapterMangaQuery()).observesTables(MangaTable.TABLE).build()).prepare();
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,32 @@
|
||||
package eu.kanade.tachiyomi.data.database.resolvers
|
||||
|
||||
import android.content.ContentValues
|
||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||
import eu.kanade.tachiyomi.data.database.inTransactionReturn
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
|
||||
class MangaViewerPutResolver : PutResolver<Manga>() {
|
||||
|
||||
override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
|
||||
val updateQuery = mapToUpdateQuery(manga)
|
||||
val contentValues = mapToContentValues(manga)
|
||||
|
||||
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
|
||||
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
|
||||
}
|
||||
|
||||
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.where("${MangaTable.COL_ID} = ?")
|
||||
.whereArgs(manga.id)
|
||||
.build()
|
||||
|
||||
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
|
||||
put(MangaTable.COL_VIEWER, manga.viewer)
|
||||
}
|
||||
|
||||
}
|
@ -49,6 +49,10 @@ object ChapterTable {
|
||||
val createMangaIdIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_MANGA_ID}_index ON $TABLE($COL_MANGA_ID)"
|
||||
|
||||
val createUnreadChaptersIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_unread_by_manga_index ON $TABLE($COL_MANGA_ID, $COL_READ) " +
|
||||
"WHERE $COL_READ = 0"
|
||||
|
||||
val sourceOrderUpdateQuery: String
|
||||
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0"
|
||||
|
||||
|
@ -60,6 +60,7 @@ object MangaTable {
|
||||
val createUrlIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_URL}_index ON $TABLE($COL_URL)"
|
||||
|
||||
val createFavoriteIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE)"
|
||||
val createLibraryIndexQuery: String
|
||||
get() = "CREATE INDEX library_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE) " +
|
||||
"WHERE $COL_FAVORITE = 1"
|
||||
}
|
||||
|
@ -23,10 +23,12 @@ import java.util.concurrent.TimeUnit
|
||||
* @param sourceManager the source manager.
|
||||
* @param preferences the preferences of the app.
|
||||
*/
|
||||
class DownloadCache(private val context: Context,
|
||||
private val provider: DownloadProvider,
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val preferences: PreferencesHelper = Injekt.get()) {
|
||||
class DownloadCache(
|
||||
private val context: Context,
|
||||
private val provider: DownloadProvider,
|
||||
private val sourceManager: SourceManager,
|
||||
private val preferences: PreferencesHelper = Injekt.get()
|
||||
) {
|
||||
|
||||
/**
|
||||
* The interval after which this cache should be invalidated. 1 hour shouldn't cause major
|
||||
@ -194,6 +196,24 @@ class DownloadCache(private val context: Context,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a list of chapters that have been deleted from this cache.
|
||||
*
|
||||
* @param chapters the list of chapter to remove.
|
||||
* @param manga the manga of the chapter.
|
||||
*/
|
||||
@Synchronized
|
||||
fun removeChapters(chapters: List<Chapter>, manga: Manga) {
|
||||
val sourceDir = rootDir.files[manga.source] ?: return
|
||||
val mangaDir = sourceDir.files[provider.getMangaDirName(manga)] ?: return
|
||||
for (chapter in chapters) {
|
||||
val chapterDirName = provider.getChapterDirName(chapter)
|
||||
if (chapterDirName in mangaDir.files) {
|
||||
mangaDir.files -= chapterDirName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a manga that has been deleted from this cache.
|
||||
*
|
||||
|
@ -7,8 +7,10 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* This class is used to manage chapter downloads in the application. It must be instantiated once
|
||||
@ -19,6 +21,11 @@ import rx.Observable
|
||||
*/
|
||||
class DownloadManager(context: Context) {
|
||||
|
||||
/**
|
||||
* The sources manager.
|
||||
*/
|
||||
private val sourceManager by injectLazy<SourceManager>()
|
||||
|
||||
/**
|
||||
* Downloads provider, used to retrieve the folders where the chapters are or should be stored.
|
||||
*/
|
||||
@ -27,12 +34,17 @@ class DownloadManager(context: Context) {
|
||||
/**
|
||||
* Cache of downloaded chapters.
|
||||
*/
|
||||
private val cache = DownloadCache(context, provider)
|
||||
private val cache = DownloadCache(context, provider, sourceManager)
|
||||
|
||||
/**
|
||||
* Downloader whose only task is to download chapters.
|
||||
*/
|
||||
private val downloader = Downloader(context, provider, cache)
|
||||
private val downloader = Downloader(context, provider, cache, sourceManager)
|
||||
|
||||
/**
|
||||
* Queue to delay the deletion of a list of chapters until triggered.
|
||||
*/
|
||||
private val pendingDeleter = DownloadPendingDeleter(context)
|
||||
|
||||
/**
|
||||
* Downloads queue, where the pending chapters are stored.
|
||||
@ -146,15 +158,20 @@ class DownloadManager(context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the directory of a downloaded chapter.
|
||||
* Deletes the directories of a list of downloaded chapters.
|
||||
*
|
||||
* @param chapter the chapter to delete.
|
||||
* @param manga the manga of the chapter.
|
||||
* @param source the source of the chapter.
|
||||
* @param chapters the list of chapters to delete.
|
||||
* @param manga the manga of the chapters.
|
||||
* @param source the source of the chapters.
|
||||
*/
|
||||
fun deleteChapter(chapter: Chapter, manga: Manga, source: Source) {
|
||||
provider.findChapterDir(chapter, manga, source)?.delete()
|
||||
cache.removeChapter(chapter, manga)
|
||||
fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source) {
|
||||
queue.remove(chapters)
|
||||
val chapterDirs = provider.findChapterDirs(chapters, manga, source)
|
||||
chapterDirs.forEach { it.delete() }
|
||||
cache.removeChapters(chapters, manga)
|
||||
if (cache.getDownloadCount(manga) == 0) { // Delete manga directory if empty
|
||||
chapterDirs.firstOrNull()?.parentFile?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -164,7 +181,30 @@ class DownloadManager(context: Context) {
|
||||
* @param source the source of the manga.
|
||||
*/
|
||||
fun deleteManga(manga: Manga, source: Source) {
|
||||
queue.remove(manga)
|
||||
provider.findMangaDir(manga, source)?.delete()
|
||||
cache.removeManga(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a list of chapters to be deleted later.
|
||||
*
|
||||
* @param chapters the list of chapters to delete.
|
||||
* @param manga the manga of the chapters.
|
||||
*/
|
||||
fun enqueueDeleteChapters(chapters: List<Chapter>, manga: Manga) {
|
||||
pendingDeleter.addChapters(chapters, manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers the execution of the deletion of pending chapters.
|
||||
*/
|
||||
fun deletePendingChapters() {
|
||||
val pendingChapters = pendingDeleter.getPendingChapters()
|
||||
for ((manga, chapters) in pendingChapters) {
|
||||
val source = sourceManager.get(manga.source) ?: continue
|
||||
deleteChapters(chapters, manga, source)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -37,7 +37,7 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
*/
|
||||
var initialQueueSize = 0
|
||||
set(value) {
|
||||
if (value != 0){
|
||||
if (value != 0) {
|
||||
isSingleChapter = (value == 1)
|
||||
}
|
||||
field = value
|
||||
@ -99,6 +99,10 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
// Open download manager when clicked
|
||||
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
|
||||
isDownloading = true
|
||||
// Pause action
|
||||
addAction(R.drawable.ic_av_pause_grey_24dp_img,
|
||||
context.getString(R.string.action_pause),
|
||||
NotificationReceiver.pauseDownloadsPendingBroadcast(context))
|
||||
}
|
||||
|
||||
val title = download.manga.title.chop(15)
|
||||
|
@ -0,0 +1,180 @@
|
||||
package eu.kanade.tachiyomi.data.download
|
||||
|
||||
import android.content.Context
|
||||
import com.github.salomonbrys.kotson.fromJson
|
||||
import com.google.gson.Gson
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* Class used to keep a list of chapters for future deletion.
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
class DownloadPendingDeleter(context: Context) {
|
||||
|
||||
/**
|
||||
* Gson instance to encode and decode chapters.
|
||||
*/
|
||||
private val gson by injectLazy<Gson>()
|
||||
|
||||
/**
|
||||
* Preferences used to store the list of chapters to delete.
|
||||
*/
|
||||
private val prefs = context.getSharedPreferences("chapters_to_delete", Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Last added chapter, used to avoid decoding from the preference too often.
|
||||
*/
|
||||
private var lastAddedEntry: Entry? = null
|
||||
|
||||
/**
|
||||
* Adds a list of chapters for future deletion.
|
||||
*
|
||||
* @param chapters the chapters to be deleted.
|
||||
* @param manga the manga of the chapters.
|
||||
*/
|
||||
@Synchronized
|
||||
fun addChapters(chapters: List<Chapter>, manga: Manga) {
|
||||
val lastEntry = lastAddedEntry
|
||||
|
||||
val newEntry = if (lastEntry != null && lastEntry.manga.id == manga.id) {
|
||||
// Append new chapters
|
||||
val newChapters = lastEntry.chapters.addUniqueById(chapters)
|
||||
|
||||
// If no chapters were added, do nothing
|
||||
if (newChapters.size == lastEntry.chapters.size) return
|
||||
|
||||
// Last entry matches the manga, reuse it to avoid decoding json from preferences
|
||||
lastEntry.copy(chapters = newChapters)
|
||||
} else {
|
||||
val existingEntry = prefs.getString(manga.id!!.toString(), null)
|
||||
if (existingEntry != null) {
|
||||
// Existing entry found on preferences, decode json and add the new chapter
|
||||
val savedEntry = gson.fromJson<Entry>(existingEntry)
|
||||
|
||||
// Append new chapters
|
||||
val newChapters = savedEntry.chapters.addUniqueById(chapters)
|
||||
|
||||
// If no chapters were added, do nothing
|
||||
if (newChapters.size == savedEntry.chapters.size) return
|
||||
|
||||
savedEntry.copy(chapters = newChapters)
|
||||
} else {
|
||||
// No entry has been found yet, create a new one
|
||||
Entry(chapters.map { it.toEntry() }, manga.toEntry())
|
||||
}
|
||||
}
|
||||
|
||||
// Save current state
|
||||
val json = gson.toJson(newEntry)
|
||||
prefs.edit().putString(newEntry.manga.id.toString(), json).apply()
|
||||
lastAddedEntry = newEntry
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of chapters to be deleted grouped by its manga.
|
||||
*
|
||||
* Note: the returned list of manga and chapters only contain basic information needed by the
|
||||
* downloader, so don't use them for anything else.
|
||||
*/
|
||||
@Synchronized
|
||||
fun getPendingChapters(): Map<Manga, List<Chapter>> {
|
||||
val entries = decodeAll()
|
||||
prefs.edit().clear().apply()
|
||||
lastAddedEntry = null
|
||||
|
||||
return entries.associate { entry ->
|
||||
entry.manga.toModel() to entry.chapters.map { it.toModel() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes all the chapters from preferences.
|
||||
*/
|
||||
private fun decodeAll(): List<Entry> {
|
||||
return prefs.all.values.mapNotNull { rawEntry ->
|
||||
try {
|
||||
(rawEntry as? String)?.let { gson.fromJson<Entry>(it) }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of chapter entries ensuring no duplicates by chapter id.
|
||||
*/
|
||||
private fun List<ChapterEntry>.addUniqueById(chapters: List<Chapter>): List<ChapterEntry> {
|
||||
val newList = toMutableList()
|
||||
for (chapter in chapters) {
|
||||
if (none { it.id == chapter.id }) {
|
||||
newList.add(chapter.toEntry())
|
||||
}
|
||||
}
|
||||
return newList
|
||||
}
|
||||
|
||||
/**
|
||||
* Class used to save an entry of chapters with their manga into preferences.
|
||||
*/
|
||||
private data class Entry(
|
||||
val chapters: List<ChapterEntry>,
|
||||
val manga: MangaEntry
|
||||
)
|
||||
|
||||
/**
|
||||
* Class used to save an entry for a chapter into preferences.
|
||||
*/
|
||||
private data class ChapterEntry(
|
||||
val id: Long,
|
||||
val url: String,
|
||||
val name: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Class used to save an entry for a manga into preferences.
|
||||
*/
|
||||
private data class MangaEntry(
|
||||
val id: Long,
|
||||
val url: String,
|
||||
val title: String,
|
||||
val source: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns a manga entry from a manga model.
|
||||
*/
|
||||
private fun Manga.toEntry(): MangaEntry {
|
||||
return MangaEntry(id!!, url, title, source)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a chapter entry from a chapter model.
|
||||
*/
|
||||
private fun Chapter.toEntry(): ChapterEntry {
|
||||
return ChapterEntry(id!!, url, name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a manga model from a manga entry.
|
||||
*/
|
||||
private fun MangaEntry.toModel(): Manga {
|
||||
return Manga.create(url, title, source).also {
|
||||
it.id = id
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a chapter model from a chapter entry.
|
||||
*/
|
||||
private fun ChapterEntry.toModel(): Chapter {
|
||||
return Chapter.create().also {
|
||||
it.id = id
|
||||
it.url = url
|
||||
it.name = name
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -81,6 +81,18 @@ class DownloadProvider(private val context: Context) {
|
||||
return mangaDir?.findFile(getChapterDirName(chapter))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of downloaded directories for the chapters that exist.
|
||||
*
|
||||
* @param chapters the chapters to query.
|
||||
* @param manga the manga of the chapter.
|
||||
* @param source the source of the chapter.
|
||||
*/
|
||||
fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): List<UniFile> {
|
||||
val mangaDir = findMangaDir(manga, source) ?: return emptyList()
|
||||
return chapters.mapNotNull { mangaDir.findFile(getChapterDirName(it)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the download directory name for a source.
|
||||
*
|
||||
@ -108,4 +120,4 @@ class DownloadProvider(private val context: Context) {
|
||||
return DiskUtil.buildValidFilename(chapter.name)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,10 @@ import uy.kohesive.injekt.injectLazy
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
class DownloadStore(context: Context) {
|
||||
class DownloadStore(
|
||||
context: Context,
|
||||
private val sourceManager: SourceManager
|
||||
) {
|
||||
|
||||
/**
|
||||
* Preference file where active downloads are stored.
|
||||
@ -26,11 +29,6 @@ class DownloadStore(context: Context) {
|
||||
*/
|
||||
private val gson: Gson by injectLazy()
|
||||
|
||||
/**
|
||||
* Source manager.
|
||||
*/
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
/**
|
||||
* Database helper.
|
||||
*/
|
||||
@ -83,7 +81,7 @@ class DownloadStore(context: Context) {
|
||||
fun restore(): List<Download> {
|
||||
val objs = preferences.all
|
||||
.mapNotNull { it.value as? String }
|
||||
.map { deserialize(it) }
|
||||
.mapNotNull { deserialize(it) }
|
||||
.sortedBy { it.order }
|
||||
|
||||
val downloads = mutableListOf<Download>()
|
||||
@ -119,8 +117,12 @@ class DownloadStore(context: Context) {
|
||||
*
|
||||
* @param string the download as string.
|
||||
*/
|
||||
private fun deserialize(string: String): DownloadObject {
|
||||
return gson.fromJson(string, DownloadObject::class.java)
|
||||
private fun deserialize(string: String): DownloadObject? {
|
||||
return try {
|
||||
gson.fromJson(string, DownloadObject::class.java)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -132,4 +134,4 @@ class DownloadStore(context: Context) {
|
||||
*/
|
||||
data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int)
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -21,7 +21,6 @@ import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* This class is the one in charge of downloading chapters.
|
||||
@ -35,28 +34,25 @@ import uy.kohesive.injekt.injectLazy
|
||||
* @param context the application context.
|
||||
* @param provider the downloads directory provider.
|
||||
* @param cache the downloads cache, used to add the downloads to the cache after their completion.
|
||||
* @param sourceManager the source manager.
|
||||
*/
|
||||
class Downloader(
|
||||
private val context: Context,
|
||||
private val provider: DownloadProvider,
|
||||
private val cache: DownloadCache
|
||||
private val cache: DownloadCache,
|
||||
private val sourceManager: SourceManager
|
||||
) {
|
||||
|
||||
/**
|
||||
* Store for persisting downloads across restarts.
|
||||
*/
|
||||
private val store = DownloadStore(context)
|
||||
private val store = DownloadStore(context, sourceManager)
|
||||
|
||||
/**
|
||||
* Queue where active downloads are kept.
|
||||
*/
|
||||
val queue = DownloadQueue(store)
|
||||
|
||||
/**
|
||||
* Source manager.
|
||||
*/
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
/**
|
||||
* Notifier for the downloader state and progress.
|
||||
*/
|
||||
@ -382,7 +378,7 @@ class Downloader(
|
||||
// Else guess from the uri.
|
||||
?: context.contentResolver.getType(file.uri)
|
||||
// Else read magic numbers.
|
||||
?: DiskUtil.findImageMime { file.openInputStream() }
|
||||
?: ImageUtil.findImageType { file.openInputStream() }?.mime
|
||||
|
||||
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.download.model
|
||||
|
||||
import com.jakewharton.rxrelay.PublishRelay
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.DownloadStore
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import rx.Observable
|
||||
@ -40,6 +41,14 @@ class DownloadQueue(
|
||||
find { it.chapter.id == chapter.id }?.let { remove(it) }
|
||||
}
|
||||
|
||||
fun remove(chapters: List<Chapter>) {
|
||||
for (chapter in chapters) { remove(chapter) }
|
||||
}
|
||||
|
||||
fun remove(manga: Manga) {
|
||||
filter { it.manga.id == manga.id }.forEach { remove(it) }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
queue.forEach { download ->
|
||||
download.setStatusSubject(null)
|
||||
|
@ -0,0 +1,74 @@
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
class PassthroughModelLoader : ModelLoader<InputStream, InputStream> {
|
||||
|
||||
override fun buildLoadData(
|
||||
model: InputStream,
|
||||
width: Int,
|
||||
height: Int,
|
||||
options: Options
|
||||
): ModelLoader.LoadData<InputStream>? {
|
||||
return ModelLoader.LoadData(ObjectKey(model), Fetcher(model))
|
||||
}
|
||||
|
||||
override fun handles(model: InputStream): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
class Fetcher(private val stream: InputStream) : DataFetcher<InputStream> {
|
||||
|
||||
override fun getDataClass(): Class<InputStream> {
|
||||
return InputStream::class.java
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
try {
|
||||
stream.close()
|
||||
} catch (e: IOException) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDataSource(): DataSource {
|
||||
return DataSource.LOCAL
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
override fun loadData(
|
||||
priority: Priority,
|
||||
callback: DataFetcher.DataCallback<in InputStream>
|
||||
) {
|
||||
callback.onDataReady(stream)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory class for creating [PassthroughModelLoader] instances.
|
||||
*/
|
||||
class Factory : ModelLoaderFactory<InputStream, InputStream> {
|
||||
|
||||
override fun build(
|
||||
multiFactory: MultiModelLoaderFactory
|
||||
): ModelLoader<InputStream, InputStream> {
|
||||
return PassthroughModelLoader()
|
||||
}
|
||||
|
||||
override fun teardown() {}
|
||||
}
|
||||
|
||||
}
|
@ -37,5 +37,7 @@ class TachiGlideModule : AppGlideModule() {
|
||||
|
||||
registry.replace(GlideUrl::class.java, InputStream::class.java, networkFactory)
|
||||
registry.append(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory())
|
||||
registry.append(InputStream::class.java, InputStream::class.java, PassthroughModelLoader
|
||||
.Factory())
|
||||
}
|
||||
}
|
||||
|
@ -38,6 +38,11 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
ACTION_DISMISS_NOTIFICATION -> dismissNotification(context, intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
|
||||
// Resume the download service
|
||||
ACTION_RESUME_DOWNLOADS -> DownloadService.start(context)
|
||||
// Pause the download service
|
||||
ACTION_PAUSE_DOWNLOADS -> {
|
||||
DownloadService.stop(context)
|
||||
downloadManager.pauseDownloads()
|
||||
}
|
||||
// Clear the download queue
|
||||
ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true)
|
||||
// Show message notification created
|
||||
@ -159,6 +164,9 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
// Called to resume downloads.
|
||||
private const val ACTION_RESUME_DOWNLOADS = "$ID.$NAME.ACTION_RESUME_DOWNLOADS"
|
||||
|
||||
// Called to pause downloads.
|
||||
private const val ACTION_PAUSE_DOWNLOADS = "$ID.$NAME.ACTION_PAUSE_DOWNLOADS"
|
||||
|
||||
// Called to clear downloads.
|
||||
private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS"
|
||||
|
||||
@ -190,6 +198,19 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [PendingIntent] that pauses the download queue
|
||||
*
|
||||
* @param context context of application
|
||||
* @return [PendingIntent]
|
||||
*/
|
||||
internal fun pauseDownloadsPendingBroadcast(context: Context): PendingIntent {
|
||||
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||
action = ACTION_PAUSE_DOWNLOADS
|
||||
}
|
||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a [PendingIntent] that clears the download queue
|
||||
*
|
||||
@ -203,7 +224,7 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
internal fun shortcutCreatedBroadcast(context: Context) : PendingIntent {
|
||||
internal fun shortcutCreatedBroadcast(context: Context): PendingIntent {
|
||||
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||
action = ACTION_SHORTCUT_CREATED
|
||||
}
|
||||
|
@ -15,6 +15,8 @@ object PreferenceKeys {
|
||||
|
||||
const val showPageNumber = "pref_show_page_number_key"
|
||||
|
||||
const val trueColor = "pref_true_color_key"
|
||||
|
||||
const val fullscreen = "fullscreen"
|
||||
|
||||
const val keepScreenOn = "pref_keep_screen_on_key"
|
||||
@ -31,8 +33,6 @@ object PreferenceKeys {
|
||||
|
||||
const val imageScaleType = "pref_image_scale_type_key"
|
||||
|
||||
const val imageDecoder = "image_decoder"
|
||||
|
||||
const val zoomStart = "pref_zoom_start_key"
|
||||
|
||||
const val readerTheme = "pref_reader_theme_key"
|
||||
@ -43,6 +43,8 @@ object PreferenceKeys {
|
||||
|
||||
const val readWithTapping = "reader_tap"
|
||||
|
||||
const val readWithLongTap = "reader_long_tap"
|
||||
|
||||
const val readWithVolumeKeys = "reader_volume_keys"
|
||||
|
||||
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
|
||||
@ -55,8 +57,6 @@ object PreferenceKeys {
|
||||
|
||||
const val autoUpdateTrack = "pref_auto_update_manga_sync_key"
|
||||
|
||||
const val askUpdateTrack = "pref_ask_update_manga_sync_key"
|
||||
|
||||
const val lastUsedCatalogueSource = "last_catalogue_source"
|
||||
|
||||
const val lastUsedCategory = "last_used_category"
|
||||
|
@ -44,6 +44,8 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true)
|
||||
|
||||
fun trueColor() = rxPrefs.getBoolean(Keys.trueColor, false)
|
||||
|
||||
fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true)
|
||||
|
||||
fun keepScreenOn() = rxPrefs.getBoolean(Keys.keepScreenOn, true)
|
||||
@ -60,8 +62,6 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
|
||||
|
||||
fun imageDecoder() = rxPrefs.getInteger(Keys.imageDecoder, 0)
|
||||
|
||||
fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1)
|
||||
|
||||
fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0)
|
||||
@ -72,6 +72,8 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun readWithTapping() = rxPrefs.getBoolean(Keys.readWithTapping, true)
|
||||
|
||||
fun readWithLongTap() = rxPrefs.getBoolean(Keys.readWithLongTap, true)
|
||||
|
||||
fun readWithVolumeKeys() = rxPrefs.getBoolean(Keys.readWithVolumeKeys, false)
|
||||
|
||||
fun readWithVolumeKeysInverted() = rxPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false)
|
||||
@ -84,8 +86,6 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true)
|
||||
|
||||
fun askUpdateTrack() = prefs.getBoolean(Keys.askUpdateTrack, false)
|
||||
|
||||
fun lastUsedCatalogueSource() = rxPrefs.getLong(Keys.lastUsedCatalogueSource, -1)
|
||||
|
||||
fun lastUsedCategory() = rxPrefs.getInteger(Keys.lastUsedCategory, 0)
|
||||
|
@ -60,12 +60,11 @@ abstract class TrackService(val id: Int) {
|
||||
get() = !getUsername().isEmpty() &&
|
||||
!getPassword().isEmpty()
|
||||
|
||||
fun getUsername() = preferences.trackUsername(this)
|
||||
fun getUsername() = preferences.trackUsername(this)!!
|
||||
|
||||
fun getPassword() = preferences.trackPassword(this)
|
||||
fun getPassword() = preferences.trackPassword(this)!!
|
||||
|
||||
fun saveCredentials(username: String, password: String) {
|
||||
preferences.setTrackCredentials(this, username, password)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -95,9 +95,15 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
||||
// 100 point
|
||||
POINT_100 -> index.toFloat()
|
||||
// 5 stars
|
||||
POINT_5 -> index * 20f
|
||||
POINT_5 -> when {
|
||||
index == 0 -> 0f
|
||||
else -> index * 20f - 10f
|
||||
}
|
||||
// Smiley
|
||||
POINT_3 -> index * 30f
|
||||
POINT_3 -> when {
|
||||
index == 0 -> 0f
|
||||
else -> index * 25f + 10f
|
||||
}
|
||||
// 10 point decimal
|
||||
POINT_10_DECIMAL -> index.toFloat()
|
||||
else -> throw Exception("Unknown score type")
|
||||
@ -108,10 +114,13 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
||||
val score = track.score
|
||||
|
||||
return when (scorePreference.getOrDefault()) {
|
||||
POINT_5 -> "${(score / 20).toInt()} ★"
|
||||
POINT_5 -> when {
|
||||
score == 0f -> "0 ★"
|
||||
else -> "${((score + 10) / 20).toInt()} ★"
|
||||
}
|
||||
POINT_3 -> when {
|
||||
score == 0f -> "0"
|
||||
score <= 30 -> "😦"
|
||||
score <= 35 -> "😦"
|
||||
score <= 60 -> "😐"
|
||||
else -> "😊"
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import rx.Observable
|
||||
import java.util.Calendar
|
||||
|
||||
|
||||
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
@ -90,7 +91,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
fun search(search: String): Observable<List<TrackSearch>> {
|
||||
val query = """
|
||||
query Search(${'$'}query: String) {
|
||||
Page (perPage: 25) {
|
||||
Page (perPage: 50) {
|
||||
media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
|
||||
id
|
||||
title {
|
||||
@ -102,6 +103,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
type
|
||||
status
|
||||
chapters
|
||||
description
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
@ -160,6 +162,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
type
|
||||
status
|
||||
chapters
|
||||
description
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
@ -244,10 +247,18 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
}
|
||||
|
||||
fun jsonToALManga(struct: JsonObject): ALManga{
|
||||
val date = try {
|
||||
val date = Calendar.getInstance()
|
||||
date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1,
|
||||
struct["startDate"]["day"].nullInt ?: 0)
|
||||
date.timeInMillis
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
|
||||
return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString,
|
||||
null, struct["type"].asString, struct["status"].asString,
|
||||
struct["startDate"]["year"].nullString.orEmpty() + struct["startDate"]["month"].nullString.orEmpty()
|
||||
+ struct["startDate"]["day"].nullString.orEmpty(), struct["chapters"].nullInt ?: 0)
|
||||
struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString,
|
||||
date, struct["chapters"].nullInt ?: 0)
|
||||
}
|
||||
|
||||
fun jsonToALUserManga(struct: JsonObject): ALUserManga{
|
||||
|
@ -1,6 +1,5 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist
|
||||
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
@ -17,7 +16,7 @@ data class ALManga(
|
||||
val description: String?,
|
||||
val type: String,
|
||||
val publishing_status: String,
|
||||
val start_date_fuzzy: String,
|
||||
val start_date_fuzzy: Long,
|
||||
val total_chapters: Int) {
|
||||
|
||||
fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply {
|
||||
@ -29,14 +28,12 @@ data class ALManga(
|
||||
tracking_url = AnilistApi.mangaUrl(media_id)
|
||||
publishing_status = this@ALManga.publishing_status
|
||||
publishing_type = type
|
||||
if (!start_date_fuzzy.isNullOrBlank()) {
|
||||
if (start_date_fuzzy != 0L) {
|
||||
start_date = try {
|
||||
val inputDf = SimpleDateFormat("yyyyMMdd", Locale.US)
|
||||
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
val date = inputDf.parse(BuildConfig.BUILD_TIME)
|
||||
outputDf.format(date)
|
||||
outputDf.format(start_date_fuzzy)
|
||||
} catch (e: Exception) {
|
||||
start_date_fuzzy.orEmpty()
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -64,6 +61,7 @@ data class ALUserManga(
|
||||
"PAUSED" -> Anilist.ON_HOLD
|
||||
"DROPPED" -> Anilist.DROPPED
|
||||
"PLANNING" -> Anilist.PLANNING
|
||||
"REPEATING" -> Anilist.REPEATING
|
||||
else -> throw NotImplementedError("Unknown status")
|
||||
}
|
||||
}
|
||||
@ -97,7 +95,7 @@ fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrD
|
||||
// Smiley
|
||||
"POINT_3" -> when {
|
||||
score == 0f -> "0"
|
||||
score <= 30 -> ":("
|
||||
score <= 35 -> ":("
|
||||
score <= 60 -> ":|"
|
||||
else -> ":)"
|
||||
}
|
||||
|
@ -16,9 +16,11 @@ import rx.Observable
|
||||
|
||||
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
|
||||
|
||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||
|
||||
private val rest = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(client.newBuilder().addInterceptor(interceptor).build())
|
||||
.client(authClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.build()
|
||||
@ -26,7 +28,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
|
||||
private val searchRest = Retrofit.Builder()
|
||||
.baseUrl(algoliaKeyUrl)
|
||||
.client(client)
|
||||
.client(authClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.build()
|
||||
|
@ -14,7 +14,7 @@ class KitsuSearchManga(obj: JsonObject) {
|
||||
private val canonicalTitle by obj.byString
|
||||
private val chapterCount = obj.get("chapterCount").nullInt
|
||||
val subType = obj.get("subtype").nullString
|
||||
val original by obj["posterImage"].byString
|
||||
val original = obj.get("posterImage").nullObj?.get("original")?.asString
|
||||
private val synopsis by obj.byString
|
||||
private var startDate = obj.get("startDate").nullString?.let {
|
||||
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
@ -28,7 +28,7 @@ class KitsuSearchManga(obj: JsonObject) {
|
||||
media_id = this@KitsuSearchManga.id
|
||||
title = canonicalTitle
|
||||
total_chapters = chapterCount ?: 0
|
||||
cover_url = original
|
||||
cover_url = original ?: ""
|
||||
summary = synopsis
|
||||
tracking_url = KitsuApi.mangaUrl(media_id)
|
||||
if (endDate == null) {
|
||||
|
@ -4,10 +4,12 @@ import android.content.Context
|
||||
import android.graphics.Color
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import java.net.URI
|
||||
|
||||
class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
||||
|
||||
@ -21,9 +23,13 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
||||
|
||||
const val DEFAULT_STATUS = READING
|
||||
const val DEFAULT_SCORE = 0
|
||||
|
||||
const val BASE_URL = "https://myanimelist.net"
|
||||
const val USER_SESSION_COOKIE = "MALSESSIONID"
|
||||
const val LOGGED_IN_COOKIE = "is_logged_in"
|
||||
}
|
||||
|
||||
private val api by lazy { MyanimelistApi(client, getUsername(), getPassword()) }
|
||||
private val api by lazy { MyanimelistApi(client) }
|
||||
|
||||
override val name: String
|
||||
get() = "MyAnimeList"
|
||||
@ -56,7 +62,7 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
||||
}
|
||||
|
||||
override fun add(track: Track): Observable<Track> {
|
||||
return api.addLibManga(track)
|
||||
return api.addLibManga(track, getCSRF())
|
||||
}
|
||||
|
||||
override fun update(track: Track): Observable<Track> {
|
||||
@ -64,11 +70,11 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
||||
track.status = COMPLETED
|
||||
}
|
||||
|
||||
return api.updateLibManga(track)
|
||||
return api.updateLibManga(track, getCSRF())
|
||||
}
|
||||
|
||||
override fun bind(track: Track): Observable<Track> {
|
||||
return api.findLibManga(track, getUsername())
|
||||
return api.findLibManga(track, getCSRF())
|
||||
.flatMap { remoteTrack ->
|
||||
if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
@ -83,11 +89,11 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
||||
}
|
||||
|
||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||
return api.search(query, getUsername())
|
||||
return api.search(query)
|
||||
}
|
||||
|
||||
override fun refresh(track: Track): Observable<Track> {
|
||||
return api.getLibManga(track, getUsername())
|
||||
return api.getLibManga(track, getCSRF())
|
||||
.map { remoteTrack ->
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
@ -96,10 +102,40 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
||||
}
|
||||
|
||||
override fun login(username: String, password: String): Completable {
|
||||
logout()
|
||||
|
||||
return api.login(username, password)
|
||||
.doOnNext { csrf -> saveCSRF(csrf) }
|
||||
.doOnNext { saveCredentials(username, password) }
|
||||
.doOnError { logout() }
|
||||
.toCompletable()
|
||||
}
|
||||
|
||||
override fun logout() {
|
||||
super.logout()
|
||||
preferences.trackToken(this).delete()
|
||||
networkService.cookies.remove(URI(BASE_URL))
|
||||
}
|
||||
|
||||
override val isLogged: Boolean
|
||||
get() = !getUsername().isEmpty() &&
|
||||
!getPassword().isEmpty() &&
|
||||
checkCookies(URI(BASE_URL)) &&
|
||||
!getCSRF().isEmpty()
|
||||
|
||||
private fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
|
||||
|
||||
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf)
|
||||
|
||||
private fun checkCookies(uri: URI): Boolean {
|
||||
var ckCount = 0
|
||||
|
||||
for (ck in networkService.cookies.get(uri)) {
|
||||
if (ck.name() == USER_SESSION_COOKIE || ck.name() == LOGGED_IN_COOKIE)
|
||||
ckCount++
|
||||
}
|
||||
|
||||
return ckCount == 2
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
package eu.kanade.tachiyomi.data.track.myanimelist
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Xml
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
@ -12,191 +11,266 @@ import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.util.selectInt
|
||||
import eu.kanade.tachiyomi.util.selectText
|
||||
import okhttp3.*
|
||||
import org.json.JSONObject
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.parser.Parser
|
||||
import org.xmlpull.v1.XmlSerializer
|
||||
import rx.Observable
|
||||
import java.io.StringWriter
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.util.zip.GZIPInputStream
|
||||
|
||||
class MyanimelistApi(private val client: OkHttpClient, username: String, password: String) {
|
||||
|
||||
private var headers = createHeaders(username, password)
|
||||
class MyanimelistApi(private val client: OkHttpClient) {
|
||||
|
||||
fun addLibManga(track: Track): Observable<Track> {
|
||||
fun addLibManga(track: Track, csrf: String): Observable<Track> {
|
||||
return Observable.defer {
|
||||
client.newCall(POST(getAddUrl(track), headers, getMangaPostPayload(track)))
|
||||
client.newCall(POST(url = getAddUrl(), body = getMangaPostPayload(track, csrf)))
|
||||
.asObservableSuccess()
|
||||
.map { track }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLibManga(track: Track): Observable<Track> {
|
||||
fun updateLibManga(track: Track, csrf: String): Observable<Track> {
|
||||
return Observable.defer {
|
||||
client.newCall(POST(getUpdateUrl(track), headers, getMangaPostPayload(track)))
|
||||
client.newCall(POST(url = getUpdateUrl(), body = getMangaPostPayload(track, csrf)))
|
||||
.asObservableSuccess()
|
||||
.map { track }
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String, username: String): Observable<List<TrackSearch>> {
|
||||
return if (query.startsWith(PREFIX_MY)) {
|
||||
val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim()
|
||||
getList(username)
|
||||
.flatMap { Observable.from(it) }
|
||||
.filter { realQuery in it.title.toLowerCase() }
|
||||
.toList()
|
||||
} else {
|
||||
client.newCall(GET(getSearchUrl(query), headers))
|
||||
.asObservable()
|
||||
.map { Jsoup.parse(Parser.unescapeEntities(it.body()!!.string(), false), "", Parser.xmlParser()) }
|
||||
.flatMap { Observable.from(it.select("entry")) }
|
||||
.filter { it.select("type").text() != "Novel" }
|
||||
.map {
|
||||
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||
title = it.selectText("title")!!
|
||||
media_id = it.selectInt("id")
|
||||
total_chapters = it.selectInt("chapters")
|
||||
summary = it.selectText("synopsis")!!
|
||||
cover_url = it.selectText("image")!!
|
||||
tracking_url = MyanimelistApi.mangaUrl(media_id)
|
||||
publishing_status = it.selectText("status")!!
|
||||
publishing_type = it.selectText("type")!!
|
||||
start_date = it.selectText("start_date")!!
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun getList(username: String): Observable<List<TrackSearch>> {
|
||||
return client
|
||||
.newCall(GET(getListUrl(username), headers))
|
||||
fun search(query: String): Observable<List<TrackSearch>> {
|
||||
return client.newCall(GET(getSearchUrl(query)))
|
||||
.asObservable()
|
||||
.map { Jsoup.parse(Parser.unescapeEntities(it.body()!!.string(), false), "", Parser.xmlParser()) }
|
||||
.flatMap { Observable.from(it.select("manga")) }
|
||||
.map {
|
||||
.flatMap { response ->
|
||||
Observable.from(Jsoup.parse(response.consumeBody())
|
||||
.select("div.js-categories-seasonal.js-block-list.list")
|
||||
.select("table").select("tbody")
|
||||
.select("tr").drop(1))
|
||||
}
|
||||
.filter { row ->
|
||||
row.select(TD)[2].text() != "Novel"
|
||||
}
|
||||
.map { row ->
|
||||
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||
title = it.selectText("series_title")!!
|
||||
media_id = it.selectInt("series_mangadb_id")
|
||||
last_chapter_read = it.selectInt("my_read_chapters")
|
||||
status = it.selectInt("my_status")
|
||||
score = it.selectInt("my_score").toFloat()
|
||||
total_chapters = it.selectInt("series_chapters")
|
||||
cover_url = it.selectText("series_image")!!
|
||||
tracking_url = MyanimelistApi.mangaUrl(media_id)
|
||||
title = row.searchTitle()
|
||||
media_id = row.searchMediaId()
|
||||
total_chapters = row.searchTotalChapters()
|
||||
summary = row.searchSummary()
|
||||
cover_url = row.searchCoverUrl()
|
||||
tracking_url = mangaUrl(media_id)
|
||||
publishing_status = row.searchPublishingStatus()
|
||||
publishing_type = row.searchPublishingType()
|
||||
start_date = row.searchStartDate()
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun findLibManga(track: Track, username: String): Observable<Track?> {
|
||||
return getList(username)
|
||||
private fun getList(csrf: String): Observable<List<TrackSearch>> {
|
||||
return getListUrl(csrf)
|
||||
.flatMap { url ->
|
||||
getListXml(url)
|
||||
}
|
||||
.flatMap { doc ->
|
||||
Observable.from(doc.select("manga"))
|
||||
}
|
||||
.map { it ->
|
||||
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||
title = it.selectText("manga_title")!!
|
||||
media_id = it.selectInt("manga_mangadb_id")
|
||||
last_chapter_read = it.selectInt("my_read_chapters")
|
||||
status = getStatus(it.selectText("my_status")!!)
|
||||
score = it.selectInt("my_score").toFloat()
|
||||
total_chapters = it.selectInt("manga_chapters")
|
||||
tracking_url = mangaUrl(media_id)
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun getListXml(url: String): Observable<Document> {
|
||||
return client.newCall(GET(url))
|
||||
.asObservable()
|
||||
.map { response ->
|
||||
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
|
||||
}
|
||||
}
|
||||
|
||||
fun findLibManga(track: Track, csrf: String): Observable<Track?> {
|
||||
return getList(csrf)
|
||||
.map { list -> list.find { it.media_id == track.media_id } }
|
||||
}
|
||||
|
||||
fun getLibManga(track: Track, username: String): Observable<Track> {
|
||||
return findLibManga(track, username)
|
||||
fun getLibManga(track: Track, csrf: String): Observable<Track> {
|
||||
return findLibManga(track, csrf)
|
||||
.map { it ?: throw Exception("Could not find manga") }
|
||||
}
|
||||
|
||||
fun login(username: String, password: String): Observable<Response> {
|
||||
headers = createHeaders(username, password)
|
||||
return client.newCall(GET(getLoginUrl(), headers))
|
||||
.asObservable()
|
||||
.doOnNext { response ->
|
||||
response.close()
|
||||
if (response.code() != 200) throw Exception("Login error")
|
||||
fun login(username: String, password: String): Observable<String> {
|
||||
return getSessionInfo()
|
||||
.flatMap { csrf ->
|
||||
login(username, password, csrf)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMangaPostPayload(track: Track): RequestBody {
|
||||
val data = xml {
|
||||
element(ENTRY_TAG) {
|
||||
if (track.last_chapter_read != 0) {
|
||||
text(CHAPTER_TAG, track.last_chapter_read.toString())
|
||||
private fun getSessionInfo(): Observable<String> {
|
||||
return client.newCall(GET(getLoginUrl()))
|
||||
.asObservable()
|
||||
.map { response ->
|
||||
Jsoup.parse(response.consumeBody())
|
||||
.select("meta[name=csrf_token]")
|
||||
.attr("content")
|
||||
}
|
||||
text(STATUS_TAG, track.status.toString())
|
||||
text(SCORE_TAG, track.score.toString())
|
||||
}
|
||||
|
||||
private fun login(username: String, password: String, csrf: String): Observable<String> {
|
||||
return client.newCall(POST(url = getLoginUrl(), body = getLoginPostBody(username, password, csrf)))
|
||||
.asObservable()
|
||||
.map { response ->
|
||||
response.use {
|
||||
if (response.priorResponse()?.code() != 302) throw Exception("Authentication error")
|
||||
}
|
||||
csrf
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLoginPostBody(username: String, password: String, csrf: String): RequestBody {
|
||||
return FormBody.Builder()
|
||||
.add("user_name", username)
|
||||
.add("password", password)
|
||||
.add("cookie", "1")
|
||||
.add("sublogin", "Login")
|
||||
.add("submit", "1")
|
||||
.add(CSRF, csrf)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun getExportPostBody(csrf: String): RequestBody {
|
||||
return FormBody.Builder()
|
||||
.add("type", "2")
|
||||
.add("subexport", "Export My List")
|
||||
.add(CSRF, csrf)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun getMangaPostPayload(track: Track, csrf: String): RequestBody {
|
||||
val body = JSONObject()
|
||||
.put("manga_id", track.media_id)
|
||||
.put("status", track.status)
|
||||
.put("score", track.score)
|
||||
.put("num_read_chapters", track.last_chapter_read)
|
||||
.put(CSRF, csrf)
|
||||
|
||||
return RequestBody.create(MediaType.parse("application/json; charset=utf-8"), body.toString())
|
||||
}
|
||||
|
||||
private fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
|
||||
.appendPath("login.php")
|
||||
.toString()
|
||||
|
||||
private fun getSearchUrl(query: String): String {
|
||||
val col = "c[]"
|
||||
return Uri.parse(baseUrl).buildUpon()
|
||||
.appendPath("manga.php")
|
||||
.appendQueryParameter("q", query)
|
||||
.appendQueryParameter(col, "a")
|
||||
.appendQueryParameter(col, "b")
|
||||
.appendQueryParameter(col, "c")
|
||||
.appendQueryParameter(col, "d")
|
||||
.appendQueryParameter(col, "e")
|
||||
.appendQueryParameter(col, "g")
|
||||
.toString()
|
||||
}
|
||||
|
||||
private fun getExportListUrl() = Uri.parse(baseUrl).buildUpon()
|
||||
.appendPath("panel.php")
|
||||
.appendQueryParameter("go", "export")
|
||||
.toString()
|
||||
|
||||
private fun getListUrl(csrf: String): Observable<String> {
|
||||
return client.newCall(POST(url = getExportListUrl(), body = getExportPostBody(csrf)))
|
||||
.asObservable()
|
||||
.map {response ->
|
||||
baseUrl + Jsoup.parse(response.consumeBody())
|
||||
.select("div.goodresult")
|
||||
.select("a")
|
||||
.attr("href")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUpdateUrl() = Uri.parse(baseModifyListUrl).buildUpon()
|
||||
.appendPath("edit.json")
|
||||
.toString()
|
||||
|
||||
private fun getAddUrl() = Uri.parse(baseModifyListUrl).buildUpon()
|
||||
.appendPath( "add.json")
|
||||
.toString()
|
||||
|
||||
private fun Response.consumeBody(): String? {
|
||||
use {
|
||||
if (it.code() != 200) throw Exception("Login error")
|
||||
return it.body()?.string()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Response.consumeXmlBody(): String? {
|
||||
use { res ->
|
||||
if (res.code() != 200) throw Exception("Export list error")
|
||||
BufferedReader(InputStreamReader(GZIPInputStream(res.body()?.source()?.inputStream()))).use { reader ->
|
||||
val sb = StringBuilder()
|
||||
reader.forEachLine { line ->
|
||||
sb.append(line)
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
|
||||
return FormBody.Builder()
|
||||
.add("data", data)
|
||||
.build()
|
||||
}
|
||||
|
||||
private inline fun xml(block: XmlSerializer.() -> Unit): String {
|
||||
val x = Xml.newSerializer()
|
||||
val writer = StringWriter()
|
||||
|
||||
with(x) {
|
||||
setOutput(writer)
|
||||
startDocument("UTF-8", false)
|
||||
block()
|
||||
endDocument()
|
||||
}
|
||||
|
||||
return writer.toString()
|
||||
}
|
||||
|
||||
private inline fun XmlSerializer.element(tag: String, block: XmlSerializer.() -> Unit) {
|
||||
startTag("", tag)
|
||||
block()
|
||||
endTag("", tag)
|
||||
}
|
||||
|
||||
private fun XmlSerializer.text(tag: String, body: String) {
|
||||
startTag("", tag)
|
||||
text(body)
|
||||
endTag("", tag)
|
||||
}
|
||||
|
||||
fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
|
||||
.appendEncodedPath("api/account/verify_credentials.xml")
|
||||
.toString()
|
||||
|
||||
fun getSearchUrl(query: String) = Uri.parse(baseUrl).buildUpon()
|
||||
.appendEncodedPath("api/manga/search.xml")
|
||||
.appendQueryParameter("q", query)
|
||||
.toString()
|
||||
|
||||
fun getListUrl(username: String) = Uri.parse(baseUrl).buildUpon()
|
||||
.appendPath("malappinfo.php")
|
||||
.appendQueryParameter("u", username)
|
||||
.appendQueryParameter("status", "all")
|
||||
.appendQueryParameter("type", "manga")
|
||||
.toString()
|
||||
|
||||
fun getUpdateUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
|
||||
.appendEncodedPath("api/mangalist/update")
|
||||
.appendPath("${track.media_id}.xml")
|
||||
.toString()
|
||||
|
||||
fun getAddUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
|
||||
.appendEncodedPath("api/mangalist/add")
|
||||
.appendPath("${track.media_id}.xml")
|
||||
.toString()
|
||||
|
||||
fun createHeaders(username: String, password: String): Headers {
|
||||
return Headers.Builder()
|
||||
.add("Authorization", Credentials.basic(username, password))
|
||||
.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C")
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val baseUrl = "https://myanimelist.net"
|
||||
const val baseMangaUrl = baseUrl + "/manga/"
|
||||
private const val baseMangaUrl = "$baseUrl/manga/"
|
||||
private const val baseModifyListUrl = "$baseUrl/ownlist/manga"
|
||||
|
||||
fun mangaUrl(remoteId: Int): String {
|
||||
return baseMangaUrl + remoteId
|
||||
}
|
||||
fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
|
||||
|
||||
private val ENTRY_TAG = "entry"
|
||||
private val CHAPTER_TAG = "chapter"
|
||||
private val SCORE_TAG = "score"
|
||||
private val STATUS_TAG = "status"
|
||||
fun Element.searchTitle() = select("strong").text()!!
|
||||
|
||||
const val PREFIX_MY = "my:"
|
||||
fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
|
||||
|
||||
fun Element.searchCoverUrl() = select("img")
|
||||
.attr("data-src")
|
||||
.split("\\?")[0]
|
||||
.replace("/r/50x70/", "/")
|
||||
|
||||
fun Element.searchMediaId() = select("div.picSurround")
|
||||
.select("a").attr("id")
|
||||
.replace("sarea", "")
|
||||
.toInt()
|
||||
|
||||
fun Element.searchSummary() = select("div.pt4")
|
||||
.first()
|
||||
.ownText()!!
|
||||
|
||||
fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") PUBLISHING else FINISHED
|
||||
|
||||
fun Element.searchPublishingType() = select(TD)[2].text()!!
|
||||
|
||||
fun Element.searchStartDate() = select(TD)[6].text()!!
|
||||
|
||||
fun getStatus(status: String) = when (status) {
|
||||
"Reading" -> 1
|
||||
"Completed" -> 2
|
||||
"On-Hold" -> 3
|
||||
"Dropped" -> 4
|
||||
"Plan to Read" -> 6
|
||||
else -> 1
|
||||
}
|
||||
|
||||
const val CSRF = "csrf_token"
|
||||
const val TD = "td"
|
||||
private const val FINISHED = "Finished"
|
||||
private const val PUBLISHING = "Publishing"
|
||||
}
|
||||
}
|
@ -1,11 +1,8 @@
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import com.squareup.duktape.Duktape
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.*
|
||||
import java.io.IOException
|
||||
|
||||
class CloudflareInterceptor : Interceptor {
|
||||
|
||||
@ -15,15 +12,35 @@ class CloudflareInterceptor : Interceptor {
|
||||
|
||||
private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""")
|
||||
|
||||
private val sPattern = Regex("""name="s" value="([^"]+)""")
|
||||
|
||||
private val kPattern = Regex("""k\s+=\s+'([^']+)';""")
|
||||
|
||||
private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
|
||||
|
||||
private interface IBase64 {
|
||||
fun decode(input: String): String
|
||||
}
|
||||
|
||||
private val b64: IBase64 = object : IBase64 {
|
||||
override fun decode(input: String): String {
|
||||
return okio.ByteString.decodeBase64(input)!!.utf8()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
|
||||
// Check if Cloudflare anti-bot is on
|
||||
if (response.code() == 503 && response.header("Server") in serverCheck) {
|
||||
return chain.proceed(resolveChallenge(response))
|
||||
return try {
|
||||
chain.proceed(resolveChallenge(response))
|
||||
} catch (e: Exception) {
|
||||
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
|
||||
// we don't crash the entire app
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
@ -42,24 +59,37 @@ class CloudflareInterceptor : Interceptor {
|
||||
val operation = operationPattern.find(content)?.groups?.get(1)?.value
|
||||
val challenge = challengePattern.find(content)?.groups?.get(1)?.value
|
||||
val pass = passPattern.find(content)?.groups?.get(1)?.value
|
||||
val s = sPattern.find(content)?.groups?.get(1)?.value
|
||||
|
||||
if (operation == null || challenge == null || pass == null) {
|
||||
// If `k` is null, it uses old methods.
|
||||
val k = kPattern.find(content)?.groups?.get(1)?.value ?: ""
|
||||
val innerHTMLValue = Regex("""<div(.*)id="$k"(.*)>(.*)</div>""")
|
||||
.find(content)?.groups?.get(3)?.value ?: ""
|
||||
|
||||
if (operation == null || challenge == null || pass == null || s == null) {
|
||||
throw Exception("Failed resolving Cloudflare challenge")
|
||||
}
|
||||
|
||||
// Export native Base64 decode function to js object.
|
||||
duktape.set("b64", IBase64::class.java, b64)
|
||||
|
||||
// Return simulated innerHTML when call DOM.
|
||||
val simulatedDocumentJS = """var document = { getElementById: function (x) { return { innerHTML: "$innerHTMLValue" }; } }"""
|
||||
|
||||
val js = operation
|
||||
.replace(Regex("""a\.value = (.+ \+ t\.length).+"""), "$1")
|
||||
.replace(Regex("""a\.value = (.+\.toFixed\(10\);).+"""), "$1")
|
||||
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
|
||||
.replace("t.length", "${domain.length}")
|
||||
.replace("\n", "")
|
||||
|
||||
val result = duktape.evaluate(js) as Double
|
||||
val result = duktape.evaluate("""$simulatedDocumentJS;$ATOB_JS;var t="$domain";$js""") as String
|
||||
|
||||
val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")!!
|
||||
.newBuilder()
|
||||
.addQueryParameter("jschl_vc", challenge)
|
||||
.addQueryParameter("pass", pass)
|
||||
.addQueryParameter("jschl_answer", "$result")
|
||||
.addQueryParameter("s", s)
|
||||
.addQueryParameter("jschl_answer", result)
|
||||
.toString()
|
||||
|
||||
val cloudflareHeaders = originalRequest.headers()
|
||||
@ -73,4 +103,8 @@ class CloudflareInterceptor : Interceptor {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
companion object {
|
||||
// atob() is browser API, Using Android's own function. (java.util.Base64 can't be used because of min API level)
|
||||
private const val ATOB_JS = """var atob = function (input) { return b64.decode(input) }"""
|
||||
}
|
||||
}
|
@ -60,6 +60,11 @@ class PersistentCookieStore(context: Context) {
|
||||
cookieMap.clear()
|
||||
}
|
||||
|
||||
fun remove(uri: URI) {
|
||||
prefs.edit().remove(uri.host).apply()
|
||||
cookieMap.remove(uri.host)
|
||||
}
|
||||
|
||||
fun get(url: HttpUrl) = get(url.uri().host)
|
||||
|
||||
fun get(uri: URI) = get(uri.host)
|
||||
|
@ -1,24 +1,22 @@
|
||||
package eu.kanade.tachiyomi.source
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.*
|
||||
import eu.kanade.tachiyomi.util.ChapterRecognition
|
||||
import eu.kanade.tachiyomi.util.DiskUtil
|
||||
import eu.kanade.tachiyomi.util.RarContentProvider
|
||||
import eu.kanade.tachiyomi.util.ZipContentProvider
|
||||
import eu.kanade.tachiyomi.util.EpubFile
|
||||
import eu.kanade.tachiyomi.util.ImageUtil
|
||||
import junrar.Archive
|
||||
import junrar.rarfile.FileHeader
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import rx.Observable
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.InputStream
|
||||
import java.util.*
|
||||
import java.util.Comparator
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
@ -107,15 +105,11 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
||||
if (thumbnail_url == null) {
|
||||
val chapters = fetchChapterList(this).toBlocking().first()
|
||||
if (chapters.isNotEmpty()) {
|
||||
val uri = fetchPageList(chapters.last()).toBlocking().first().firstOrNull()?.uri
|
||||
if (uri != null) {
|
||||
val input = context.contentResolver.openInputStream(uri)
|
||||
try {
|
||||
val dest = updateCover(context, this, input)
|
||||
thumbnail_url = dest?.absolutePath
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
try {
|
||||
val dest = updateCover(chapters.last(), this)
|
||||
thumbnail_url = dest?.absolutePath
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -135,7 +129,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
||||
val chapters = getBaseDirectories(context)
|
||||
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
||||
.flatten()
|
||||
.filter { it.isDirectory || isSupportedFormat(it.extension) }
|
||||
.filter { it.isDirectory || isSupportedFile(it.extension) }
|
||||
.map { chapterFile ->
|
||||
SChapter.create().apply {
|
||||
url = "${manga.url}/${chapterFile.name}"
|
||||
@ -150,7 +144,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
||||
ChapterRecognition.parseChapterNumber(this, manga)
|
||||
}
|
||||
}
|
||||
.sortedWith(Comparator<SChapter> { c1, c2 ->
|
||||
.sortedWith(Comparator { c1, c2 ->
|
||||
val c = c2.chapter_number.compareTo(c1.chapter_number)
|
||||
if (c == 0) comparator.compare(c2.name, c1.name) else c
|
||||
})
|
||||
@ -159,160 +153,90 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
return Observable.error(Exception("Unused"))
|
||||
}
|
||||
|
||||
private fun isSupportedFile(extension: String): Boolean {
|
||||
return extension.toLowerCase() in setOf("zip", "rar", "cbr", "cbz", "epub")
|
||||
}
|
||||
|
||||
fun getFormat(chapter: SChapter): Format {
|
||||
val baseDirs = getBaseDirectories(context)
|
||||
|
||||
for (dir in baseDirs) {
|
||||
val chapFile = File(dir, chapter.url)
|
||||
if (!chapFile.exists()) continue
|
||||
|
||||
return Observable.just(getLoader(chapFile).load())
|
||||
return getFormat(chapFile)
|
||||
}
|
||||
|
||||
return Observable.error(Exception("Chapter not found"))
|
||||
throw Exception("Chapter not found")
|
||||
}
|
||||
|
||||
private fun isSupportedFormat(extension: String): Boolean {
|
||||
return extension.equals("zip", true) || extension.equals("cbz", true)
|
||||
|| extension.equals("rar", true) || extension.equals("cbr", true)
|
||||
|| extension.equals("epub", true)
|
||||
}
|
||||
|
||||
private fun getLoader(file: File): Loader {
|
||||
private fun getFormat(file: File): Format {
|
||||
val extension = file.extension
|
||||
return if (file.isDirectory) {
|
||||
DirectoryLoader(file)
|
||||
Format.Directory(file)
|
||||
} else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
|
||||
ZipLoader(file)
|
||||
} else if (extension.equals("epub", true)) {
|
||||
EpubLoader(file)
|
||||
Format.Zip(file)
|
||||
} else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
|
||||
RarLoader(file)
|
||||
Format.Rar(file)
|
||||
} else if (extension.equals("epub", true)) {
|
||||
Format.Epub(file)
|
||||
} else {
|
||||
throw Exception("Invalid chapter format")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCover(chapter: SChapter, manga: SManga): File? {
|
||||
val format = getFormat(chapter)
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return when (format) {
|
||||
is Format.Directory -> {
|
||||
val entry = format.file.listFiles()
|
||||
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||
.find { !it.isDirectory && ImageUtil.isImage(it.name, { FileInputStream(it) }) }
|
||||
|
||||
entry?.let { updateCover(context, manga, it.inputStream())}
|
||||
}
|
||||
is Format.Zip -> {
|
||||
ZipFile(format.file).use { zip ->
|
||||
val entry = zip.entries().toList()
|
||||
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||
.find { !it.isDirectory && ImageUtil.isImage(it.name, { zip.getInputStream(it) }) }
|
||||
|
||||
entry?.let { updateCover(context, manga, zip.getInputStream(it) )}
|
||||
}
|
||||
}
|
||||
is Format.Rar -> {
|
||||
Archive(format.file).use { archive ->
|
||||
val entry = archive.fileHeaders
|
||||
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
|
||||
.find { !it.isDirectory && ImageUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
|
||||
|
||||
entry?.let { updateCover(context, manga, archive.getInputStream(it) )}
|
||||
}
|
||||
}
|
||||
is Format.Epub -> {
|
||||
EpubFile(format.file).use { epub ->
|
||||
val entry = epub.getImagesFromPages()
|
||||
.firstOrNull()
|
||||
?.let { epub.getEntry(it) }
|
||||
|
||||
entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true))
|
||||
|
||||
override fun getFilterList() = FilterList(OrderBy())
|
||||
|
||||
interface Loader {
|
||||
fun load(): List<Page>
|
||||
sealed class Format {
|
||||
data class Directory(val file: File) : Format()
|
||||
data class Zip(val file: File) : Format()
|
||||
data class Rar(val file: File): Format()
|
||||
data class Epub(val file: File) : Format()
|
||||
}
|
||||
|
||||
class DirectoryLoader(val file: File) : Loader {
|
||||
override fun load(): List<Page> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return file.listFiles()
|
||||
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { FileInputStream(it) }) }
|
||||
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||
.map { Uri.fromFile(it) }
|
||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
||||
}
|
||||
}
|
||||
|
||||
class ZipLoader(val file: File) : Loader {
|
||||
override fun load(): List<Page> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return ZipFile(file).use { zip ->
|
||||
zip.entries().toList()
|
||||
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { zip.getInputStream(it) }) }
|
||||
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/${it.name}") }
|
||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RarLoader(val file: File) : Loader {
|
||||
override fun load(): List<Page> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return Archive(file).use { archive ->
|
||||
archive.fileHeaders
|
||||
.filter { !it.isDirectory && DiskUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
|
||||
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
|
||||
.map { Uri.parse("content://${RarContentProvider.PROVIDER}${file.absolutePath}!-/${it.fileNameString}") }
|
||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EpubLoader(val file: File) : Loader {
|
||||
|
||||
override fun load(): List<Page> {
|
||||
ZipFile(file).use { zip ->
|
||||
val allEntries = zip.entries().toList()
|
||||
val ref = getPackageHref(zip)
|
||||
val doc = getPackageDocument(zip, ref)
|
||||
val pages = getPagesFromDocument(doc)
|
||||
val hrefs = getHrefMap(ref, allEntries.map { it.name })
|
||||
return getImagesFromPages(zip, pages, hrefs)
|
||||
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/$it") }
|
||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the package document.
|
||||
*/
|
||||
private fun getPackageHref(zip: ZipFile): String {
|
||||
val meta = zip.getEntry("META-INF/container.xml")
|
||||
if (meta != null) {
|
||||
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
|
||||
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
|
||||
if (path != null) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return "OEBPS/content.opf"
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the package document where all the files are listed.
|
||||
*/
|
||||
private fun getPackageDocument(zip: ZipFile, ref: String): Document {
|
||||
val entry = zip.getEntry(ref)
|
||||
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the pages from the epub.
|
||||
*/
|
||||
private fun getPagesFromDocument(document: Document): List<String> {
|
||||
val pages = document.select("manifest > item")
|
||||
.filter { "application/xhtml+xml" == it.attr("media-type") }
|
||||
.associateBy { it.attr("id") }
|
||||
|
||||
val spine = document.select("spine > itemref").map { it.attr("idref") }
|
||||
return spine.mapNotNull { pages[it] }.map { it.attr("href") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the images contained in every page from the epub.
|
||||
*/
|
||||
private fun getImagesFromPages(zip: ZipFile, pages: List<String>, hrefs: Map<String, String>): List<String> {
|
||||
return pages.map { page ->
|
||||
val entry = zip.getEntry(hrefs[page])
|
||||
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
||||
document.getElementsByTag("img").mapNotNull { hrefs[it.attr("src")] }
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map with a relative url as key and abolute url as path.
|
||||
*/
|
||||
private fun getHrefMap(packageHref: String, entries: List<String>): Map<String, String> {
|
||||
val lastSlashPos = packageHref.lastIndexOf('/')
|
||||
if (lastSlashPos < 0) {
|
||||
return entries.associateBy { it }
|
||||
}
|
||||
return entries.associateBy { entry ->
|
||||
if (entry.isNotBlank() && entry.length > lastSlashPos) {
|
||||
entry.substring(lastSlashPos + 1)
|
||||
} else {
|
||||
entry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,21 +2,18 @@ package eu.kanade.tachiyomi.source.model
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.network.ProgressListener
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
|
||||
import rx.subjects.Subject
|
||||
|
||||
class Page(
|
||||
open class Page(
|
||||
val index: Int,
|
||||
var url: String = "",
|
||||
var imageUrl: String? = null,
|
||||
@Transient var uri: Uri? = null
|
||||
@Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
|
||||
) : ProgressListener {
|
||||
|
||||
val number: Int
|
||||
get() = index + 1
|
||||
|
||||
@Transient lateinit var chapter: ReaderChapter
|
||||
|
||||
@Transient @Volatile var status: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
|
@ -1,88 +1,15 @@
|
||||
package eu.kanade.tachiyomi.source.online
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
|
||||
// TODO: this should be handled with a different approach.
|
||||
|
||||
/**
|
||||
* Chapter cache.
|
||||
*/
|
||||
private val chapterCache: ChapterCache by injectLazy()
|
||||
|
||||
/**
|
||||
* Returns an observable with the page list for a chapter. It tries to return the page list from
|
||||
* the local cache, otherwise fallbacks to network.
|
||||
*
|
||||
* @param chapter the chapter whose page list has to be fetched.
|
||||
*/
|
||||
fun HttpSource.fetchPageListFromCacheThenNet(chapter: Chapter): Observable<List<Page>> {
|
||||
return chapterCache
|
||||
.getPageListFromCache(chapter)
|
||||
.onErrorResumeNext { fetchPageList(chapter) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the page with the downloaded image.
|
||||
*
|
||||
* @param page the page whose source image has to be downloaded.
|
||||
*/
|
||||
fun HttpSource.fetchImageFromCacheThenNet(page: Page): Observable<Page> {
|
||||
return if (page.imageUrl.isNullOrEmpty())
|
||||
getImageUrl(page).flatMap { getCachedImage(it) }
|
||||
else
|
||||
getCachedImage(page)
|
||||
}
|
||||
|
||||
fun HttpSource.getImageUrl(page: Page): Observable<Page> {
|
||||
page.status = Page.LOAD_PAGE
|
||||
return fetchImageUrl(page)
|
||||
.doOnError { page.status = Page.ERROR }
|
||||
.onErrorReturn { null }
|
||||
.doOnNext { page.imageUrl = it }
|
||||
.map { page }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the page that gets the image from the chapter or fallbacks to
|
||||
* network and copies it to the cache calling [cacheImage].
|
||||
*
|
||||
* @param page the page.
|
||||
*/
|
||||
fun HttpSource.getCachedImage(page: Page): Observable<Page> {
|
||||
val imageUrl = page.imageUrl ?: return Observable.just(page)
|
||||
|
||||
return Observable.just(page)
|
||||
.flatMap {
|
||||
if (!chapterCache.isImageInCache(imageUrl)) {
|
||||
cacheImage(page)
|
||||
} else {
|
||||
Observable.just(page)
|
||||
}
|
||||
}
|
||||
.doOnNext {
|
||||
page.uri = Uri.fromFile(chapterCache.getImageFile(imageUrl))
|
||||
page.status = Page.READY
|
||||
}
|
||||
.doOnError { page.status = Page.ERROR }
|
||||
.onErrorReturn { page }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the page that downloads the image to [ChapterCache].
|
||||
*
|
||||
* @param page the page.
|
||||
*/
|
||||
private fun HttpSource.cacheImage(page: Page): Observable<Page> {
|
||||
page.status = Page.DOWNLOAD_IMAGE
|
||||
return fetchImage(page)
|
||||
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
|
||||
.map { page }
|
||||
.doOnError { page.status = Page.ERROR }
|
||||
.onErrorReturn { null }
|
||||
.doOnNext { page.imageUrl = it }
|
||||
.map { page }
|
||||
}
|
||||
|
||||
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
|
||||
|
@ -18,7 +18,7 @@ class Mangasee : ParsedHttpSource() {
|
||||
|
||||
override val name = "Mangasee"
|
||||
|
||||
override val baseUrl = "http://mangaseeonline.net"
|
||||
override val baseUrl = "http://mangaseeonline.us"
|
||||
|
||||
override val lang = "en"
|
||||
|
||||
@ -246,4 +246,4 @@ class Mangasee : ParsedHttpSource() {
|
||||
Genre("Yuri")
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.model.*
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
@ -36,7 +37,7 @@ class Mintmanga : ParsedHttpSource() {
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.thumbnail_url = element.select("img.lazy").first().attr("data-original")
|
||||
manga.thumbnail_url = element.select("img.lazy").first()?.attr("data-original")
|
||||
element.select("h3 > a").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.attr("title")
|
||||
@ -52,8 +53,25 @@ class Mintmanga : ParsedHttpSource() {
|
||||
override fun latestUpdatesNextPageSelector() = "a.nextLink"
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val genres = filters.filterIsInstance<Genre>().joinToString("&") { it.id + arrayOf("=", "=in", "=ex")[it.state] }
|
||||
return GET("$baseUrl/search/advanced?q=$query&$genres", headers)
|
||||
val url = HttpUrl.parse("$baseUrl/search/advanced")!!.newBuilder()
|
||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
||||
when (filter) {
|
||||
is GenreList -> filter.state.forEach { genre ->
|
||||
if (genre.state != Filter.TriState.STATE_IGNORE) {
|
||||
url.addQueryParameter(genre.id, arrayOf("=", "=in", "=ex")[genre.state])
|
||||
}
|
||||
}
|
||||
is Category -> filter.state.forEach { category ->
|
||||
if (category.state != Filter.TriState.STATE_IGNORE) {
|
||||
url.addQueryParameter(category.id, arrayOf("=", "=in", "=ex")[category.state])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!query.isEmpty()) {
|
||||
url.addQueryParameter("q", query)
|
||||
}
|
||||
return GET(url.toString().replace("=%3D", "="), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
@ -61,7 +79,7 @@ class Mintmanga : ParsedHttpSource() {
|
||||
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
|
||||
|
||||
// max 200 results
|
||||
override fun searchMangaNextPageSelector() = null
|
||||
override fun searchMangaNextPageSelector(): Nothing? = null
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val infoElement = document.select("div.leftContent").first()
|
||||
@ -133,7 +151,12 @@ class Mintmanga : ParsedHttpSource() {
|
||||
var i = 0
|
||||
while (m.find()) {
|
||||
val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',')
|
||||
pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2]))
|
||||
val url = if (urlParts[1].isEmpty() && urlParts[2].startsWith("/static/")) {
|
||||
baseUrl + urlParts[2]
|
||||
} else {
|
||||
urlParts[1] + urlParts[0] + urlParts[2]
|
||||
}
|
||||
pages.add(Page(i++, "", url))
|
||||
}
|
||||
return pages
|
||||
}
|
||||
@ -153,13 +176,34 @@ class Mintmanga : ParsedHttpSource() {
|
||||
}
|
||||
|
||||
private class Genre(name: String, val id: String) : Filter.TriState(name)
|
||||
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
|
||||
private class Category(categories: List<Genre>) : Filter.Group<Genre>("Category", categories)
|
||||
|
||||
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")]
|
||||
* .map(el => `Genre("${el.textContent.trim()}", $"{el.getAttribute('onclick')
|
||||
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")]
|
||||
* .map(el => `Genre("${el.textContent.trim()}", "${el.getAttribute('onclick')
|
||||
* .substr(31,el.getAttribute('onclick').length-33)"})`).join(',\n')
|
||||
* on http://mintmanga.com/search/advanced
|
||||
*/
|
||||
override fun getFilterList() = FilterList(
|
||||
Category(getCategoryList()),
|
||||
GenreList(getGenreList())
|
||||
)
|
||||
|
||||
private fun getCategoryList() = listOf(
|
||||
Genre("В цвете", "el_4614"),
|
||||
Genre("Веб", "el_1355"),
|
||||
Genre("Выпуск приостановлен", "el_5232"),
|
||||
Genre("Ёнкома", "el_2741"),
|
||||
Genre("Комикс западный", "el_1903"),
|
||||
Genre("Комикс русский", "el_2173"),
|
||||
Genre("Манхва", "el_1873"),
|
||||
Genre("Маньхуа", "el_1875"),
|
||||
Genre("Не Яой", "el_1874"),
|
||||
Genre("Ранобэ", "el_5688"),
|
||||
Genre("Сборник", "el_1348")
|
||||
)
|
||||
|
||||
private fun getGenreList() = listOf(
|
||||
Genre("арт", "el_2220"),
|
||||
Genre("бара", "el_1353"),
|
||||
Genre("боевик", "el_1346"),
|
||||
|
@ -4,6 +4,7 @@ import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.model.*
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
@ -36,7 +37,7 @@ class Readmanga : ParsedHttpSource() {
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.thumbnail_url = element.select("img.lazy").first().attr("data-original")
|
||||
manga.thumbnail_url = element.select("img.lazy").first()?.attr("data-original")
|
||||
element.select("h3 > a").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.attr("title")
|
||||
@ -52,8 +53,25 @@ class Readmanga : ParsedHttpSource() {
|
||||
override fun latestUpdatesNextPageSelector() = "a.nextLink"
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val genres = filters.filterIsInstance<Genre>().joinToString("&") { it.id + arrayOf("=", "=in", "=ex")[it.state] }
|
||||
return GET("$baseUrl/search/advanced?q=$query&$genres", headers)
|
||||
val url = HttpUrl.parse("$baseUrl/search/advanced")!!.newBuilder()
|
||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
||||
when (filter) {
|
||||
is GenreList -> filter.state.forEach { genre ->
|
||||
if (genre.state != Filter.TriState.STATE_IGNORE) {
|
||||
url.addQueryParameter(genre.id, arrayOf("=", "=in", "=ex")[genre.state])
|
||||
}
|
||||
}
|
||||
is Category -> filter.state.forEach { category ->
|
||||
if (category.state != Filter.TriState.STATE_IGNORE) {
|
||||
url.addQueryParameter(category.id, arrayOf("=", "=in", "=ex")[category.state])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!query.isEmpty()) {
|
||||
url.addQueryParameter("q", query)
|
||||
}
|
||||
return GET(url.toString().replace("=%3D", "="), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
@ -61,7 +79,7 @@ class Readmanga : ParsedHttpSource() {
|
||||
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
|
||||
|
||||
// max 200 results
|
||||
override fun searchMangaNextPageSelector() = null
|
||||
override fun searchMangaNextPageSelector(): Nothing? = null
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val infoElement = document.select("div.leftContent").first()
|
||||
@ -133,7 +151,12 @@ class Readmanga : ParsedHttpSource() {
|
||||
var i = 0
|
||||
while (m.find()) {
|
||||
val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',')
|
||||
pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2]))
|
||||
val url = if (urlParts[1].isEmpty() && urlParts[2].startsWith("/static/")) {
|
||||
baseUrl + urlParts[2]
|
||||
} else {
|
||||
urlParts[1] + urlParts[0] + urlParts[2]
|
||||
}
|
||||
pages.add(Page(i++, "", url))
|
||||
}
|
||||
return pages
|
||||
}
|
||||
@ -153,6 +176,8 @@ class Readmanga : ParsedHttpSource() {
|
||||
}
|
||||
|
||||
private class Genre(name: String, val id: String) : Filter.TriState(name)
|
||||
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
|
||||
private class Category(categories: List<Genre>) : Filter.Group<Genre>("Category", categories)
|
||||
|
||||
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")]
|
||||
* .map(el => `Genre("${el.textContent.trim()}", $"{el.getAttribute('onclick')
|
||||
@ -160,6 +185,23 @@ class Readmanga : ParsedHttpSource() {
|
||||
* on http://readmanga.me/search/advanced
|
||||
*/
|
||||
override fun getFilterList() = FilterList(
|
||||
Category(getCategoryList()),
|
||||
GenreList(getGenreList())
|
||||
)
|
||||
|
||||
private fun getCategoryList() = listOf(
|
||||
Genre("В цвете", "el_7290"),
|
||||
Genre("Веб", "el_2160"),
|
||||
Genre("Выпуск приостановлен", "el_8033"),
|
||||
Genre("Ёнкома", "el_2161"),
|
||||
Genre("Комикс западный", "el_3515"),
|
||||
Genre("Манхва", "el_3001"),
|
||||
Genre("Маньхуа", "el_3002"),
|
||||
Genre("Ранобэ", "el_8575"),
|
||||
Genre("Сборник", "el_2157")
|
||||
)
|
||||
|
||||
private fun getGenreList() = listOf(
|
||||
Genre("арт", "el_5685"),
|
||||
Genre("боевик", "el_2155"),
|
||||
Genre("боевые искусства", "el_2143"),
|
||||
|
@ -42,9 +42,8 @@ class CatalogueGridHolder(private val view: View, private val adapter: FlexibleA
|
||||
.load(manga)
|
||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
||||
.centerCrop()
|
||||
.skipMemoryCache(true)
|
||||
.placeholder(android.R.color.transparent)
|
||||
.into(StateImageViewTarget(thumbnail, progress))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -45,7 +45,6 @@ class CatalogueListHolder(private val view: View, adapter: FlexibleAdapter<*>) :
|
||||
.centerCrop()
|
||||
.circleCrop()
|
||||
.dontAnimate()
|
||||
.skipMemoryCache(true)
|
||||
.placeholder(android.R.color.transparent)
|
||||
.into(thumbnail)
|
||||
}
|
||||
|
@ -53,8 +53,11 @@ class CatalogueSearchHolder(view: View, val adapter: CatalogueSearchAdapter) :
|
||||
val source = item.source
|
||||
val results = item.results
|
||||
|
||||
// Set Title witch country code if available.
|
||||
title.text = if (!source.lang.isEmpty()) "${source.name} (${source.lang})" else source.name
|
||||
val titlePrefix = if (item.highlighted) "▶" else ""
|
||||
val langSuffix = if (source.lang.isNotEmpty()) " (${source.lang})" else ""
|
||||
|
||||
// Set Title with country code if available.
|
||||
title.text = titlePrefix + source.name + langSuffix
|
||||
|
||||
when {
|
||||
results == null -> {
|
||||
@ -101,5 +104,4 @@ class CatalogueSearchHolder(view: View, val adapter: CatalogueSearchAdapter) :
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -9,9 +9,11 @@ import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
/**
|
||||
* Item that contains search result information.
|
||||
*
|
||||
* @param source contains information about search result.
|
||||
* @param source the source for the search results.
|
||||
* @param results the search results.
|
||||
* @param highlighted whether this search item should be highlighted/marked in the catalogue search view.
|
||||
*/
|
||||
class CatalogueSearchItem(val source: CatalogueSource, val results: List<CatalogueSearchCardItem>?)
|
||||
class CatalogueSearchItem(val source: CatalogueSource, val results: List<CatalogueSearchCardItem>?, val highlighted: Boolean = false)
|
||||
: AbstractFlexibleItem<CatalogueSearchHolder>() {
|
||||
|
||||
/**
|
||||
@ -61,4 +63,4 @@ class CatalogueSearchItem(val source: CatalogueSource, val results: List<Catalog
|
||||
return source.id.toInt()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -98,7 +98,14 @@ open class CatalogueSearchPresenter(
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates a search for mnaga per catalogue.
|
||||
* Creates a catalogue search item
|
||||
*/
|
||||
protected open fun createCatalogueSearchItem(source: CatalogueSource, results: List<CatalogueSearchCardItem>?): CatalogueSearchItem {
|
||||
return CatalogueSearchItem(source, results)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates a search for manga per catalogue.
|
||||
*
|
||||
* @param query query on which to search.
|
||||
*/
|
||||
@ -113,7 +120,7 @@ open class CatalogueSearchPresenter(
|
||||
initializeFetchImageSubscription()
|
||||
|
||||
// Create items with the initial state
|
||||
val initialItems = sources.map { CatalogueSearchItem(it, null) }
|
||||
val initialItems = sources.map { createCatalogueSearchItem(it, null) }
|
||||
var items = initialItems
|
||||
|
||||
fetchSourcesSubscription?.unsubscribe()
|
||||
@ -125,7 +132,7 @@ open class CatalogueSearchPresenter(
|
||||
.map { it.mangas.take(10) } // Get at most 10 manga from search result.
|
||||
.map { it.map { networkToLocalManga(it, source.id) } } // Convert to local manga.
|
||||
.doOnNext { fetchImage(it, source) } // Load manga covers.
|
||||
.map { CatalogueSearchItem(source, it.map { CatalogueSearchCardItem(it) }) }
|
||||
.map { createCatalogueSearchItem(source, it.map { CatalogueSearchCardItem(it) }) }
|
||||
}, 5)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
// Update matching source with the obtained results
|
||||
@ -212,4 +219,4 @@ open class CatalogueSearchPresenter(
|
||||
}
|
||||
return localManga
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -131,6 +131,10 @@ class LibraryPresenter(
|
||||
|
||||
// Filter when there are no downloads.
|
||||
if (filterDownloaded) {
|
||||
// Local manga are always downloaded
|
||||
if (item.manga.source == LocalSource.ID) {
|
||||
return@f true
|
||||
}
|
||||
// Don't bother with directory checking if download count has been set.
|
||||
if (item.downloadCount != -1) {
|
||||
return@f item.downloadCount > 0
|
||||
|
@ -76,6 +76,7 @@ class MainActivity : BaseActivity() {
|
||||
setTheme(when (preferences.theme()) {
|
||||
2 -> R.style.Theme_Tachiyomi_Dark
|
||||
3 -> R.style.Theme_Tachiyomi_Amoled
|
||||
4 -> R.style.Theme_Tachiyomi_DarkBlue
|
||||
else -> R.style.Theme_Tachiyomi
|
||||
})
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.LocalSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
|
||||
@ -21,7 +22,7 @@ import rx.schedulers.Schedulers
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Presenter of [ChaptersController].
|
||||
@ -180,7 +181,7 @@ class ChaptersPresenter(
|
||||
observable = observable.filter { it.read }
|
||||
}
|
||||
if (onlyDownloaded()) {
|
||||
observable = observable.filter { it.isDownloaded }
|
||||
observable = observable.filter { it.isDownloaded || it.manga.source == LocalSource.ID }
|
||||
}
|
||||
if (onlyBookmarked()) {
|
||||
observable = observable.filter { it.bookmark }
|
||||
@ -274,9 +275,8 @@ class ChaptersPresenter(
|
||||
* @param chapters the list of chapters to delete.
|
||||
*/
|
||||
fun deleteChapters(chapters: List<ChapterItem>) {
|
||||
Observable.from(chapters)
|
||||
.doOnNext { deleteChapter(it) }
|
||||
.toList()
|
||||
Observable.just(chapters)
|
||||
.doOnNext { deleteChaptersInternal(chapters) }
|
||||
.doOnNext { if (onlyDownloaded()) refreshChapters() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
@ -286,14 +286,15 @@ class ChaptersPresenter(
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a chapter from disk. This method is called in a background thread.
|
||||
* @param chapter the chapter to delete.
|
||||
* Deletes a list of chapters from disk. This method is called in a background thread.
|
||||
* @param chapters the chapters to delete.
|
||||
*/
|
||||
private fun deleteChapter(chapter: ChapterItem) {
|
||||
downloadManager.queue.remove(chapter)
|
||||
downloadManager.deleteChapter(chapter, manga, source)
|
||||
chapter.status = Download.NOT_DOWNLOADED
|
||||
chapter.download = null
|
||||
private fun deleteChaptersInternal(chapters: List<ChapterItem>) {
|
||||
downloadManager.deleteChapters(chapters, manga, source)
|
||||
chapters.forEach {
|
||||
it.status = Download.NOT_DOWNLOADED
|
||||
it.download = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -383,8 +383,9 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
|
||||
/**
|
||||
* Update swipe refresh to start showing refresh in progress spinner.
|
||||
*/
|
||||
fun onFetchMangaError() {
|
||||
fun onFetchMangaError(error: Throwable) {
|
||||
setRefreshing(false)
|
||||
activity?.toast(error.message)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -90,9 +90,7 @@ class MangaInfoPresenter(
|
||||
.doOnNext { sendMangaToView() }
|
||||
.subscribeFirst({ view, _ ->
|
||||
view.onFetchMangaDone()
|
||||
}, { view, _ ->
|
||||
view.onFetchMangaError()
|
||||
})
|
||||
}, MangaInfoController::onFetchMangaError)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -2,6 +2,8 @@ package eu.kanade.tachiyomi.ui.migration
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchCardItem
|
||||
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchItem
|
||||
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchPresenter
|
||||
|
||||
class SearchPresenter(
|
||||
@ -10,8 +12,13 @@ class SearchPresenter(
|
||||
) : CatalogueSearchPresenter(initialQuery) {
|
||||
|
||||
override fun getEnabledSources(): List<CatalogueSource> {
|
||||
// Filter out the source of the selected manga
|
||||
// Put the source of the selected manga at the top
|
||||
return super.getEnabledSources()
|
||||
.filterNot { it.id == manga.source }
|
||||
.sortedByDescending { it.id == manga.source }
|
||||
}
|
||||
}
|
||||
|
||||
override fun createCatalogueSearchItem(source: CatalogueSource, results: List<CatalogueSearchCardItem>?): CatalogueSearchItem {
|
||||
//Set the catalogue search item as highlighted if the source matches that of the selected manga
|
||||
return CatalogueSearchItem(source, results, source.id == manga.source)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,36 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
|
||||
/**
|
||||
* Load strategy using the source order. This is the default ordering.
|
||||
*/
|
||||
class ChapterLoadBySource {
|
||||
fun get(allChapters: List<Chapter>): List<Chapter> {
|
||||
return allChapters.sortedByDescending { it.source_order }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load strategy using unique chapter numbers with same scanlator preference.
|
||||
*/
|
||||
class ChapterLoadByNumber {
|
||||
fun get(allChapters: List<Chapter>, selectedChapter: Chapter): List<Chapter> {
|
||||
val chapters = mutableListOf<Chapter>()
|
||||
val chaptersByNumber = allChapters.groupBy { it.chapter_number }
|
||||
|
||||
for ((number, chaptersForNumber) in chaptersByNumber) {
|
||||
val preferredChapter = when {
|
||||
// Make sure the selected chapter is always present
|
||||
number == selectedChapter.chapter_number -> selectedChapter
|
||||
// If there is only one chapter for this number, use it
|
||||
chaptersForNumber.size == 1 -> chaptersForNumber.first()
|
||||
// Prefer a chapter of the same scanlator as the selected
|
||||
else -> chaptersForNumber.find { it.scanlator == selectedChapter.scanlator }
|
||||
?: chaptersForNumber.first()
|
||||
}
|
||||
chapters.add(preferredChapter)
|
||||
}
|
||||
return chapters.sortedBy { it.chapter_number }
|
||||
}
|
||||
}
|
@ -1,170 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.source.online.fetchImageFromCacheThenNet
|
||||
import eu.kanade.tachiyomi.source.online.fetchPageListFromCacheThenNet
|
||||
import eu.kanade.tachiyomi.util.plusAssign
|
||||
import rx.Observable
|
||||
import rx.schedulers.Schedulers
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.concurrent.PriorityBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class ChapterLoader(
|
||||
private val downloadManager: DownloadManager,
|
||||
private val manga: Manga,
|
||||
private val source: Source
|
||||
) {
|
||||
|
||||
private val prefs by injectLazy<PreferencesHelper>()
|
||||
|
||||
private val queue = PriorityBlockingQueue<PriorityPage>()
|
||||
private val subscriptions = CompositeSubscription()
|
||||
|
||||
fun init() {
|
||||
prepareOnlineReading()
|
||||
}
|
||||
|
||||
fun restart() {
|
||||
cleanup()
|
||||
init()
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
subscriptions.clear()
|
||||
queue.clear()
|
||||
}
|
||||
|
||||
private fun prepareOnlineReading() {
|
||||
if (source !is HttpSource) return
|
||||
|
||||
for(i in 1 .. prefs.eh_readerThreads().getOrDefault())
|
||||
subscriptions += Observable.defer { Observable.just(queue.take().page) }
|
||||
.filter { it.status == Page.QUEUE }
|
||||
.concatMap { source.fetchImageFromCacheThenNet(it) }
|
||||
.repeat()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe({
|
||||
}, { error ->
|
||||
if (error !is InterruptedException) {
|
||||
Timber.e(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun loadChapter(chapter: ReaderChapter) = Observable.just(chapter)
|
||||
.flatMap {
|
||||
if (chapter.pages == null)
|
||||
retrievePageList(chapter)
|
||||
else
|
||||
Observable.just(chapter.pages!!)
|
||||
}
|
||||
.doOnNext { pages ->
|
||||
if (pages.isEmpty()) {
|
||||
throw Exception("Page list is empty")
|
||||
}
|
||||
|
||||
// Now that the number of pages is known, fix the requested page if the last one
|
||||
// was requested.
|
||||
if (chapter.requestedPage == -1) {
|
||||
chapter.requestedPage = pages.lastIndex
|
||||
}
|
||||
|
||||
loadPages(chapter)
|
||||
}
|
||||
.map { chapter }
|
||||
|
||||
private fun retrievePageList(chapter: ReaderChapter) = Observable.just(chapter)
|
||||
.flatMap {
|
||||
// Check if the chapter is downloaded.
|
||||
chapter.isDownloaded = downloadManager.isChapterDownloaded(chapter, manga, true)
|
||||
|
||||
if (chapter.isDownloaded) {
|
||||
// Fetch the page list from disk.
|
||||
downloadManager.buildPageList(source, manga, chapter)
|
||||
} else {
|
||||
(source as? HttpSource)?.fetchPageListFromCacheThenNet(chapter)
|
||||
?: source.fetchPageList(chapter)
|
||||
}
|
||||
}
|
||||
.doOnNext { pages ->
|
||||
chapter.pages = pages
|
||||
pages.forEach { it.chapter = chapter }
|
||||
}
|
||||
|
||||
private fun loadPages(chapter: ReaderChapter) {
|
||||
if (!chapter.isDownloaded) {
|
||||
loadOnlinePages(chapter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadOnlinePages(chapter: ReaderChapter) {
|
||||
chapter.pages?.let { pages ->
|
||||
val startPage = chapter.requestedPage
|
||||
val pagesToLoad = if (startPage == 0)
|
||||
pages
|
||||
else
|
||||
pages.drop(startPage)
|
||||
|
||||
pagesToLoad.forEach { queue.offer(PriorityPage(it, 0)) }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPage(page: Page) {
|
||||
queue.offer(PriorityPage(page, 0))
|
||||
}
|
||||
|
||||
fun loadPriorizedPage(page: Page) {
|
||||
queue.offer(PriorityPage(page, 1))
|
||||
}
|
||||
|
||||
fun retryPage(page: Page) {
|
||||
// --> EH
|
||||
if(prefs.eh_readerInstantRetry().getOrDefault())
|
||||
boostPage(page)
|
||||
else
|
||||
// <-- EH
|
||||
queue.offer(PriorityPage(page, 2))
|
||||
}
|
||||
|
||||
|
||||
// --> EH
|
||||
fun boostPage(page: Page) {
|
||||
if(source is HttpSource && page.status == Page.QUEUE)
|
||||
subscriptions += Observable.just(page)
|
||||
.concatMap { source.fetchImageFromCacheThenNet(it) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe({
|
||||
}, { error ->
|
||||
if (error !is InterruptedException) {
|
||||
Timber.e(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
// <-- EH
|
||||
|
||||
|
||||
private data class PriorityPage(val page: Page, val priority: Int): Comparable<PriorityPage> {
|
||||
|
||||
companion object {
|
||||
private val idGenerator = AtomicInteger()
|
||||
}
|
||||
|
||||
private val identifier = idGenerator.incrementAndGet()
|
||||
|
||||
override fun compareTo(other: PriorityPage): Int {
|
||||
val p = other.priority.compareTo(priority)
|
||||
return if (p != 0) p else identifier.compareTo(other.identifier)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -12,8 +12,13 @@ import android.text.style.ScaleXSpan
|
||||
import android.util.AttributeSet
|
||||
import android.widget.TextView
|
||||
|
||||
class PageIndicatorTextView(context: Context, attrs: AttributeSet? = null) :
|
||||
AppCompatTextView(context, attrs) {
|
||||
/**
|
||||
* Page indicator found at the bottom of the reader
|
||||
*/
|
||||
class PageIndicatorTextView(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : AppCompatTextView(context, attrs) {
|
||||
|
||||
private val fillColor = Color.rgb(235, 235, 235)
|
||||
private val strokeColor = Color.rgb(45, 45, 45)
|
||||
@ -53,4 +58,4 @@ class PageIndicatorTextView(context: Context, attrs: AttributeSet? = null) :
|
||||
isAccessible = true
|
||||
}!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
1223
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt
Executable file → Normal file
1223
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
@ -1,13 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
|
||||
class ReaderChapter(c: Chapter) : Chapter by c {
|
||||
|
||||
@Transient var pages: List<Page>? = null
|
||||
|
||||
var isDownloaded: Boolean = false
|
||||
|
||||
var requestedPage: Int = 0
|
||||
}
|
@ -1,19 +1,19 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import android.app.Dialog
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.support.annotation.ColorInt
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.design.widget.BottomSheetBehavior
|
||||
import android.support.design.widget.BottomSheetDialog
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.SeekBar
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.util.plusAssign
|
||||
import eu.kanade.tachiyomi.widget.SimpleSeekBarListener
|
||||
import kotlinx.android.synthetic.main.reader_custom_filter_dialog.view.*
|
||||
import kotlinx.android.synthetic.main.reader_color_filter.*
|
||||
import kotlinx.android.synthetic.main.reader_color_filter_sheet.*
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
@ -21,33 +21,18 @@ import uy.kohesive.injekt.injectLazy
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Custom dialog which can be used to set overlay value's
|
||||
* Color filter sheet to toggle custom filter and brightness overlay.
|
||||
*/
|
||||
class ReaderCustomFilterDialog : DialogFragment() {
|
||||
class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activity) {
|
||||
|
||||
companion object {
|
||||
/** Integer mask of alpha value **/
|
||||
private const val ALPHA_MASK: Long = 0xFF000000
|
||||
|
||||
/** Integer mask of red value **/
|
||||
private const val RED_MASK: Long = 0x00FF0000
|
||||
|
||||
/** Integer mask of green value **/
|
||||
private const val GREEN_MASK: Long = 0x0000FF00
|
||||
|
||||
/** Integer mask of blue value **/
|
||||
private const val BLUE_MASK: Long = 0x000000FF
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides operations to manage preferences
|
||||
*/
|
||||
private val preferences by injectLazy<PreferencesHelper>()
|
||||
|
||||
private var behavior: BottomSheetBehavior<*>? = null
|
||||
|
||||
/**
|
||||
* Subscription used for filter overlay
|
||||
* Subscriptions used for this dialog
|
||||
*/
|
||||
private lateinit var subscriptions: CompositeSubscription
|
||||
private val subscriptions = CompositeSubscription()
|
||||
|
||||
/**
|
||||
* Subscription used for custom brightness overlay
|
||||
@ -59,34 +44,18 @@ class ReaderCustomFilterDialog : DialogFragment() {
|
||||
*/
|
||||
private var customFilterColorSubscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* This method will be called after onCreate(Bundle)
|
||||
* @param savedState The last saved instance state of the Fragment.
|
||||
*/
|
||||
override fun onCreateDialog(savedState: Bundle?): Dialog {
|
||||
val dialog = MaterialDialog.Builder(activity!!)
|
||||
.customView(R.layout.reader_custom_filter_dialog, false)
|
||||
.positiveText(android.R.string.ok)
|
||||
.build()
|
||||
init {
|
||||
val view = activity.layoutInflater.inflate(R.layout.reader_color_filter_sheet, null)
|
||||
setContentView(view)
|
||||
|
||||
subscriptions = CompositeSubscription()
|
||||
onViewCreated(dialog.view, savedState)
|
||||
behavior = BottomSheetBehavior.from(view.parent as ViewGroup)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after onCreateView()
|
||||
* @param view The View returned by onCreateDialog.
|
||||
* @param savedInstanceState If non-null, this fragment is being re-constructed
|
||||
*/
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(view) {
|
||||
// Initialize subscriptions.
|
||||
subscriptions += preferences.colorFilter().asObservable()
|
||||
.subscribe { setColorFilter(it, view) }
|
||||
.subscribe { setColorFilter(it, view) }
|
||||
|
||||
subscriptions += preferences.customBrightness().asObservable()
|
||||
.subscribe { setCustomBrightness(it, view) }
|
||||
.subscribe { setCustomBrightness(it, view) }
|
||||
|
||||
// Get color and update values
|
||||
val color = preferences.colorFilterValue().getOrDefault()
|
||||
@ -154,7 +123,19 @@ class ReaderCustomFilterDialog : DialogFragment() {
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
behavior?.skipCollapsed = true
|
||||
behavior?.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
subscriptions.unsubscribe()
|
||||
customBrightnessSubscription = null
|
||||
customFilterColorSubscription = null
|
||||
}
|
||||
|
||||
/**
|
||||
@ -210,8 +191,8 @@ class ReaderCustomFilterDialog : DialogFragment() {
|
||||
private fun setCustomBrightness(enabled: Boolean, view: View) {
|
||||
if (enabled) {
|
||||
customBrightnessSubscription = preferences.customBrightnessValue().asObservable()
|
||||
.sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
|
||||
.subscribe { setCustomBrightnessValue(it, view) }
|
||||
.sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
|
||||
.subscribe { setCustomBrightnessValue(it, view) }
|
||||
|
||||
subscriptions.add(customBrightnessSubscription)
|
||||
} else {
|
||||
@ -249,13 +230,13 @@ class ReaderCustomFilterDialog : DialogFragment() {
|
||||
private fun setColorFilter(enabled: Boolean, view: View) {
|
||||
if (enabled) {
|
||||
customFilterColorSubscription = preferences.colorFilterValue().asObservable()
|
||||
.sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
|
||||
.subscribe { setColorFilterValue(it, view) }
|
||||
.sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
|
||||
.subscribe { setColorFilterValue(it, view) }
|
||||
|
||||
subscriptions.add(customFilterColorSubscription)
|
||||
} else {
|
||||
customFilterColorSubscription?.let { subscriptions.remove(it) }
|
||||
view.color_overlay.visibility = View.GONE
|
||||
color_overlay.visibility = View.GONE
|
||||
}
|
||||
setColorFilterSeekBar(enabled, view)
|
||||
}
|
||||
@ -319,12 +300,18 @@ class ReaderCustomFilterDialog : DialogFragment() {
|
||||
return color and 0xFF
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when dialog is dismissed
|
||||
*/
|
||||
override fun onDestroyView() {
|
||||
subscriptions.unsubscribe()
|
||||
super.onDestroyView()
|
||||
private companion object {
|
||||
/** Integer mask of alpha value **/
|
||||
const val ALPHA_MASK: Long = 0xFF000000
|
||||
|
||||
/** Integer mask of red value **/
|
||||
const val RED_MASK: Long = 0x00FF0000
|
||||
|
||||
/** Integer mask of green value **/
|
||||
const val GREEN_MASK: Long = 0x0000FF00
|
||||
|
||||
/** Integer mask of blue value **/
|
||||
const val BLUE_MASK: Long = 0x000000FF
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
|
||||
class ReaderEvent(val manga: Manga, val chapter: Chapter)
|
@ -0,0 +1,74 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import android.os.Bundle
|
||||
import android.support.design.widget.BottomSheetDialog
|
||||
import android.view.ViewGroup
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import kotlinx.android.synthetic.main.reader_page_sheet.*
|
||||
|
||||
/**
|
||||
* Sheet to show when a page is long clicked.
|
||||
*/
|
||||
class ReaderPageSheet(
|
||||
private val activity: ReaderActivity,
|
||||
private val page: ReaderPage
|
||||
) : BottomSheetDialog(activity) {
|
||||
|
||||
/**
|
||||
* View used on this sheet.
|
||||
*/
|
||||
private val view = activity.layoutInflater.inflate(R.layout.reader_page_sheet, null)
|
||||
|
||||
init {
|
||||
setContentView(view)
|
||||
|
||||
set_as_cover_layout.setOnClickListener { setAsCover() }
|
||||
share_layout.setOnClickListener { share() }
|
||||
save_layout.setOnClickListener { save() }
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val width = context.resources.getDimensionPixelSize(R.dimen.bottom_sheet_width)
|
||||
if (width > 0) {
|
||||
window?.setLayout(width, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the image of this page as the cover of the manga.
|
||||
*/
|
||||
private fun setAsCover() {
|
||||
if (page.status != Page.READY) return
|
||||
|
||||
MaterialDialog.Builder(activity)
|
||||
.content(activity.getString(R.string.confirm_set_image_as_cover))
|
||||
.positiveText(android.R.string.yes)
|
||||
.negativeText(android.R.string.no)
|
||||
.onPositive { _, _ ->
|
||||
activity.setAsCover(page)
|
||||
dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares the image of this page with external apps.
|
||||
*/
|
||||
private fun share() {
|
||||
activity.shareImage(page)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the image of this page on external storage.
|
||||
*/
|
||||
private fun save() {
|
||||
activity.saveImage(page)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
}
|
1034
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt
Executable file → Normal file
1034
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,45 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.support.v7.widget.AppCompatSeekBar
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
|
||||
/**
|
||||
* Seekbar to show current chapter progress.
|
||||
*/
|
||||
class ReaderSeekBar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : AppCompatSeekBar(context, attrs) {
|
||||
|
||||
/**
|
||||
* Whether the seekbar should draw from right to left.
|
||||
*/
|
||||
var isRTL = false
|
||||
|
||||
/**
|
||||
* Draws the seekbar, translating the canvas if using a right to left reader.
|
||||
*/
|
||||
override fun draw(canvas: Canvas) {
|
||||
if (isRTL) {
|
||||
val px = width / 2f
|
||||
val py = height / 2f
|
||||
|
||||
canvas.scale(-1f, 1f, px, py)
|
||||
}
|
||||
super.draw(canvas)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles touch events, translating coordinates if using a right to left reader.
|
||||
*/
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (isRTL) {
|
||||
event.setLocation(width - event.x, event.y)
|
||||
}
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
}
|
@ -1,119 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.view.View
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.util.plusAssign
|
||||
import eu.kanade.tachiyomi.util.visibleIf
|
||||
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
|
||||
import kotlinx.android.synthetic.main.reader_settings_dialog.view.*
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.concurrent.TimeUnit.MILLISECONDS
|
||||
|
||||
class ReaderSettingsDialog : DialogFragment() {
|
||||
|
||||
private val preferences by injectLazy<PreferencesHelper>()
|
||||
|
||||
private lateinit var subscriptions: CompositeSubscription
|
||||
|
||||
override fun onCreateDialog(savedState: Bundle?): Dialog {
|
||||
val dialog = MaterialDialog.Builder(activity!!)
|
||||
.title(R.string.label_settings)
|
||||
.customView(R.layout.reader_settings_dialog, true)
|
||||
.positiveText(android.R.string.ok)
|
||||
.build()
|
||||
|
||||
subscriptions = CompositeSubscription()
|
||||
onViewCreated(dialog.view, savedState)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedState: Bundle?) = with(view) {
|
||||
viewer.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
|
||||
subscriptions += Observable.timer(250, MILLISECONDS, AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
val readerActivity = activity as? ReaderActivity
|
||||
if (readerActivity != null) {
|
||||
readerActivity.presenter.updateMangaViewer(position)
|
||||
readerActivity.recreate()
|
||||
}
|
||||
}
|
||||
}
|
||||
viewer.setSelection((activity as ReaderActivity).presenter.manga.viewer, false)
|
||||
|
||||
rotation_mode.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
|
||||
subscriptions += Observable.timer(250, MILLISECONDS)
|
||||
.subscribe {
|
||||
preferences.rotation().set(position + 1)
|
||||
}
|
||||
}
|
||||
rotation_mode.setSelection(preferences.rotation().getOrDefault() - 1, false)
|
||||
|
||||
scale_type.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
|
||||
preferences.imageScaleType().set(position + 1)
|
||||
}
|
||||
scale_type.setSelection(preferences.imageScaleType().getOrDefault() - 1, false)
|
||||
|
||||
zoom_start.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
|
||||
preferences.zoomStart().set(position + 1)
|
||||
}
|
||||
zoom_start.setSelection(preferences.zoomStart().getOrDefault() - 1, false)
|
||||
|
||||
image_decoder.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
|
||||
preferences.imageDecoder().set(position)
|
||||
}
|
||||
image_decoder.setSelection(preferences.imageDecoder().getOrDefault(), false)
|
||||
|
||||
background_color.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
|
||||
preferences.readerTheme().set(position)
|
||||
}
|
||||
background_color.setSelection(preferences.readerTheme().getOrDefault(), false)
|
||||
|
||||
show_page_number.isChecked = preferences.showPageNumber().getOrDefault()
|
||||
show_page_number.setOnCheckedChangeListener { _, isChecked ->
|
||||
preferences.showPageNumber().set(isChecked)
|
||||
}
|
||||
|
||||
fullscreen.isChecked = preferences.fullscreen().getOrDefault()
|
||||
fullscreen.setOnCheckedChangeListener { _, isChecked ->
|
||||
preferences.fullscreen().set(isChecked)
|
||||
}
|
||||
|
||||
crop_borders.isChecked = preferences.cropBorders().getOrDefault()
|
||||
crop_borders.setOnCheckedChangeListener { _, isChecked ->
|
||||
preferences.cropBorders().set(isChecked)
|
||||
}
|
||||
|
||||
crop_borders_webtoon.isChecked = preferences.cropBordersWebtoon().getOrDefault()
|
||||
crop_borders_webtoon.setOnCheckedChangeListener { _, isChecked ->
|
||||
preferences.cropBordersWebtoon().set(isChecked)
|
||||
}
|
||||
|
||||
val readerActivity = activity as? ReaderActivity
|
||||
val isWebtoonViewer = if (readerActivity != null) {
|
||||
val mangaViewer = readerActivity.presenter.manga.viewer
|
||||
val viewer = if (mangaViewer == 0) preferences.defaultViewer() else mangaViewer
|
||||
viewer == ReaderActivity.WEBTOON
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
crop_borders.visibleIf { !isWebtoonViewer }
|
||||
crop_borders_webtoon.visibleIf { isWebtoonViewer }
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
subscriptions.unsubscribe()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,105 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import android.os.Bundle
|
||||
import android.support.design.widget.BottomSheetDialog
|
||||
import android.support.v4.widget.NestedScrollView
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.Spinner
|
||||
import com.f2prateek.rx.preferences.Preference
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
|
||||
import eu.kanade.tachiyomi.util.visible
|
||||
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
|
||||
import kotlinx.android.synthetic.main.reader_settings_sheet.*
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* Sheet to show reader and viewer preferences.
|
||||
*/
|
||||
class ReaderSettingsSheet(private val activity: ReaderActivity) : BottomSheetDialog(activity) {
|
||||
|
||||
/**
|
||||
* Preferences helper.
|
||||
*/
|
||||
private val preferences by injectLazy<PreferencesHelper>()
|
||||
|
||||
init {
|
||||
// Use activity theme for this layout
|
||||
val view = activity.layoutInflater.inflate(R.layout.reader_settings_sheet, null)
|
||||
val scroll = NestedScrollView(activity)
|
||||
scroll.addView(view)
|
||||
setContentView(scroll)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the sheet is created. It initializes the listeners and values of the preferences.
|
||||
*/
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
initGeneralPreferences()
|
||||
|
||||
when (activity.viewer) {
|
||||
is PagerViewer -> initPagerPreferences()
|
||||
is WebtoonViewer -> initWebtoonPreferences()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Init general reader preferences.
|
||||
*/
|
||||
private fun initGeneralPreferences() {
|
||||
viewer.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
|
||||
activity.presenter.setMangaViewer(position)
|
||||
}
|
||||
viewer.setSelection(activity.presenter.manga?.viewer ?: 0, false)
|
||||
|
||||
rotation_mode.bindToPreference(preferences.rotation(), 1)
|
||||
background_color.bindToPreference(preferences.readerTheme())
|
||||
show_page_number.bindToPreference(preferences.showPageNumber())
|
||||
fullscreen.bindToPreference(preferences.fullscreen())
|
||||
keepscreen.bindToPreference(preferences.keepScreenOn())
|
||||
long_tap.bindToPreference(preferences.readWithLongTap())
|
||||
}
|
||||
|
||||
/**
|
||||
* Init the preferences for the pager reader.
|
||||
*/
|
||||
private fun initPagerPreferences() {
|
||||
pager_prefs_group.visible()
|
||||
scale_type.bindToPreference(preferences.imageScaleType(), 1)
|
||||
zoom_start.bindToPreference(preferences.zoomStart(), 1)
|
||||
crop_borders.bindToPreference(preferences.cropBorders())
|
||||
page_transitions.bindToPreference(preferences.pageTransitions())
|
||||
}
|
||||
|
||||
/**
|
||||
* Init the preferences for the webtoon reader.
|
||||
*/
|
||||
private fun initWebtoonPreferences() {
|
||||
webtoon_prefs_group.visible()
|
||||
crop_borders_webtoon.bindToPreference(preferences.cropBordersWebtoon())
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a checkbox or switch view with a boolean preference.
|
||||
*/
|
||||
private fun CompoundButton.bindToPreference(pref: Preference<Boolean>) {
|
||||
isChecked = pref.getOrDefault()
|
||||
setOnCheckedChangeListener { _, isChecked -> pref.set(isChecked) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a spinner to an int preference with an optional offset for the value.
|
||||
*/
|
||||
private fun Spinner.bindToPreference(pref: Preference<Int>, offset: Int = 0) {
|
||||
onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
|
||||
pref.set(position + offset)
|
||||
}
|
||||
setSelection(pref.getOrDefault() - offset, false)
|
||||
}
|
||||
|
||||
}
|
@ -16,6 +16,7 @@ import java.io.File
|
||||
* Class used to show BigPictureStyle notifications
|
||||
*/
|
||||
class SaveImageNotifier(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Notification builder.
|
||||
*/
|
||||
@ -35,12 +36,12 @@ class SaveImageNotifier(private val context: Context) {
|
||||
*/
|
||||
fun onComplete(file: File) {
|
||||
val bitmap = GlideApp.with(context)
|
||||
.asBitmap()
|
||||
.load(file)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
.submit(720, 1280)
|
||||
.get()
|
||||
.asBitmap()
|
||||
.load(file)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
.submit(720, 1280)
|
||||
.get()
|
||||
|
||||
if (bitmap != null) {
|
||||
showCompleteNotification(file, bitmap)
|
||||
|
@ -0,0 +1,84 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.source.LocalSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Loader used to retrieve the [PageLoader] for a given chapter.
|
||||
*/
|
||||
class ChapterLoader(
|
||||
private val downloadManager: DownloadManager,
|
||||
private val manga: Manga,
|
||||
private val source: Source
|
||||
) {
|
||||
|
||||
/**
|
||||
* Returns a completable that assigns the page loader and loads the its pages. It just
|
||||
* completes if the chapter is already loaded.
|
||||
*/
|
||||
fun loadChapter(chapter: ReaderChapter): Completable {
|
||||
if (chapter.state is ReaderChapter.State.Loaded) {
|
||||
return Completable.complete()
|
||||
}
|
||||
|
||||
return Observable.just(chapter)
|
||||
.doOnNext { chapter.state = ReaderChapter.State.Loading }
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMap {
|
||||
Timber.d("Loading pages for ${chapter.chapter.name}")
|
||||
|
||||
val loader = getPageLoader(it)
|
||||
chapter.pageLoader = loader
|
||||
|
||||
loader.getPages().take(1).doOnNext { pages ->
|
||||
pages.forEach { it.chapter = chapter }
|
||||
}
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext { pages ->
|
||||
if (pages.isEmpty()) {
|
||||
throw Exception("Page list is empty")
|
||||
}
|
||||
|
||||
chapter.state = ReaderChapter.State.Loaded(pages)
|
||||
|
||||
// If the chapter is partially read, set the starting page to the last the user read
|
||||
// otherwise use the requested page.
|
||||
if (!chapter.chapter.read) {
|
||||
chapter.requestedPage = chapter.chapter.last_page_read
|
||||
}
|
||||
}
|
||||
.toCompletable()
|
||||
.doOnError { chapter.state = ReaderChapter.State.Error(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the page loader to use for this [chapter].
|
||||
*/
|
||||
private fun getPageLoader(chapter: ReaderChapter): PageLoader {
|
||||
val isDownloaded = downloadManager.isChapterDownloaded(chapter.chapter, manga, true)
|
||||
return when {
|
||||
isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager)
|
||||
source is HttpSource -> HttpPageLoader(chapter, source)
|
||||
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
|
||||
when (format) {
|
||||
is LocalSource.Format.Directory -> DirectoryPageLoader(format.file)
|
||||
is LocalSource.Format.Zip -> ZipPageLoader(format.file)
|
||||
is LocalSource.Format.Rar -> RarPageLoader(format.file)
|
||||
is LocalSource.Format.Epub -> EpubPageLoader(format.file)
|
||||
}
|
||||
}
|
||||
else -> error("Loader not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.util.ImageUtil
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import rx.Observable
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
|
||||
/**
|
||||
* Loader used to load a chapter from a directory given on [file].
|
||||
*/
|
||||
class DirectoryPageLoader(val file: File) : PageLoader() {
|
||||
|
||||
/**
|
||||
* Returns an observable containing the pages found on this directory ordered with a natural
|
||||
* comparator.
|
||||
*/
|
||||
override fun getPages(): Observable<List<ReaderPage>> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
|
||||
return file.listFiles()
|
||||
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
|
||||
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||
.mapIndexed { i, file ->
|
||||
val streamFn = { FileInputStream(file) }
|
||||
ReaderPage(i).apply {
|
||||
stream = streamFn
|
||||
status = Page.READY
|
||||
}
|
||||
}
|
||||
.let { Observable.just(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that emits a ready state.
|
||||
*/
|
||||
override fun getPage(page: ReaderPage): Observable<Int> {
|
||||
return Observable.just(Page.READY)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import android.app.Application
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* Loader used to load a chapter from the downloaded chapters.
|
||||
*/
|
||||
class DownloadPageLoader(
|
||||
private val chapter: ReaderChapter,
|
||||
private val manga: Manga,
|
||||
private val source: Source,
|
||||
private val downloadManager: DownloadManager
|
||||
) : PageLoader() {
|
||||
|
||||
/**
|
||||
* The application context. Needed to open input streams.
|
||||
*/
|
||||
private val context by injectLazy<Application>()
|
||||
|
||||
/**
|
||||
* Returns an observable containing the pages found on this downloaded chapter.
|
||||
*/
|
||||
override fun getPages(): Observable<List<ReaderPage>> {
|
||||
return downloadManager.buildPageList(source, manga, chapter.chapter)
|
||||
.map { pages ->
|
||||
pages.map { page ->
|
||||
ReaderPage(page.index, page.url, page.imageUrl, {
|
||||
context.contentResolver.openInputStream(page.uri)
|
||||
}).apply {
|
||||
status = Page.READY
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPage(page: ReaderPage): Observable<Int> {
|
||||
return Observable.just(Page.READY) // TODO maybe check if file still exists?
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.util.EpubFile
|
||||
import rx.Observable
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Loader used to load a chapter from a .epub file.
|
||||
*/
|
||||
class EpubPageLoader(file: File) : PageLoader() {
|
||||
|
||||
/**
|
||||
* The epub file.
|
||||
*/
|
||||
private val epub = EpubFile(file)
|
||||
|
||||
/**
|
||||
* Recycles this loader and the open zip.
|
||||
*/
|
||||
override fun recycle() {
|
||||
super.recycle()
|
||||
epub.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable containing the pages found on this zip archive ordered with a natural
|
||||
* comparator.
|
||||
*/
|
||||
override fun getPages(): Observable<List<ReaderPage>> {
|
||||
return epub.getImagesFromPages()
|
||||
.mapIndexed { i, path ->
|
||||
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) }
|
||||
ReaderPage(i).apply {
|
||||
stream = streamFn
|
||||
status = Page.READY
|
||||
}
|
||||
}
|
||||
.let { Observable.just(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that emits a ready state unless the loader was recycled.
|
||||
*/
|
||||
override fun getPage(page: ReaderPage): Observable<Int> {
|
||||
return Observable.just(if (isRecycled) {
|
||||
Page.ERROR
|
||||
} else {
|
||||
Page.READY
|
||||
})
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,226 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.util.plusAssign
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import rx.schedulers.Schedulers
|
||||
import rx.subjects.PublishSubject
|
||||
import rx.subjects.SerializedSubject
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.concurrent.PriorityBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Loader used to load chapters from an online source.
|
||||
*/
|
||||
class HttpPageLoader(
|
||||
private val chapter: ReaderChapter,
|
||||
private val source: HttpSource,
|
||||
private val chapterCache: ChapterCache = Injekt.get()
|
||||
) : PageLoader() {
|
||||
|
||||
/**
|
||||
* A queue used to manage requests one by one while allowing priorities.
|
||||
*/
|
||||
private val queue = PriorityBlockingQueue<PriorityPage>()
|
||||
|
||||
/**
|
||||
* Current active subscriptions.
|
||||
*/
|
||||
private val subscriptions = CompositeSubscription()
|
||||
|
||||
init {
|
||||
subscriptions += Observable.defer { Observable.just(queue.take().page) }
|
||||
.filter { it.status == Page.QUEUE }
|
||||
.concatMap { source.fetchImageFromCacheThenNet(it) }
|
||||
.repeat()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe({
|
||||
}, { error ->
|
||||
if (error !is InterruptedException) {
|
||||
Timber.e(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Recycles this loader and the active subscriptions and queue.
|
||||
*/
|
||||
override fun recycle() {
|
||||
super.recycle()
|
||||
subscriptions.unsubscribe()
|
||||
queue.clear()
|
||||
|
||||
// Cache current page list progress for online chapters to allow a faster reopen
|
||||
val pages = chapter.pages
|
||||
if (pages != null) {
|
||||
Completable
|
||||
.fromAction {
|
||||
// Convert to pages without reader information
|
||||
val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) }
|
||||
chapterCache.putPageListToCache(chapter.chapter, pagesToSave)
|
||||
}
|
||||
.onErrorComplete()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable with the page list for a chapter. It tries to return the page list from
|
||||
* the local cache, otherwise fallbacks to network.
|
||||
*/
|
||||
override fun getPages(): Observable<List<ReaderPage>> {
|
||||
return chapterCache
|
||||
.getPageListFromCache(chapter.chapter)
|
||||
.onErrorResumeNext { source.fetchPageList(chapter.chapter) }
|
||||
.map { pages ->
|
||||
pages.mapIndexed { index, page -> // Don't trust sources and use our own indexing
|
||||
ReaderPage(index, page.url, page.imageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that loads a page through the queue and listens to its result to
|
||||
* emit new states. It handles re-enqueueing pages if they were evicted from the cache.
|
||||
*/
|
||||
override fun getPage(page: ReaderPage): Observable<Int> {
|
||||
return Observable.defer {
|
||||
val imageUrl = page.imageUrl
|
||||
|
||||
// Check if the image has been deleted
|
||||
if (page.status == Page.READY && imageUrl != null && !chapterCache.isImageInCache(imageUrl)) {
|
||||
page.status = Page.QUEUE
|
||||
}
|
||||
|
||||
// Automatically retry failed pages when subscribed to this page
|
||||
if (page.status == Page.ERROR) {
|
||||
page.status = Page.QUEUE
|
||||
}
|
||||
|
||||
val statusSubject = SerializedSubject(PublishSubject.create<Int>())
|
||||
page.setStatusSubject(statusSubject)
|
||||
|
||||
if (page.status == Page.QUEUE) {
|
||||
queue.offer(PriorityPage(page, 1))
|
||||
}
|
||||
|
||||
preloadNextPages(page, 4)
|
||||
|
||||
statusSubject.startWith(page.status)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preloads the given [amount] of pages after the [currentPage] with a lower priority.
|
||||
*/
|
||||
private fun preloadNextPages(currentPage: ReaderPage, amount: Int) {
|
||||
val pageIndex = currentPage.index
|
||||
val pages = currentPage.chapter.pages ?: return
|
||||
if (pageIndex == pages.lastIndex) return
|
||||
val nextPages = pages.subList(pageIndex + 1, Math.min(pageIndex + 1 + amount, pages.size))
|
||||
for (nextPage in nextPages) {
|
||||
if (nextPage.status == Page.QUEUE) {
|
||||
queue.offer(PriorityPage(nextPage, 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries a page. This method is only called from user interaction on the viewer.
|
||||
*/
|
||||
override fun retryPage(page: ReaderPage) {
|
||||
if (page.status == Page.ERROR) {
|
||||
page.status = Page.QUEUE
|
||||
}
|
||||
queue.offer(PriorityPage(page, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class used to keep ordering of pages in order to maintain priority.
|
||||
*/
|
||||
private data class PriorityPage(
|
||||
val page: ReaderPage,
|
||||
val priority: Int
|
||||
): Comparable<PriorityPage> {
|
||||
|
||||
companion object {
|
||||
private val idGenerator = AtomicInteger()
|
||||
}
|
||||
|
||||
private val identifier = idGenerator.incrementAndGet()
|
||||
|
||||
override fun compareTo(other: PriorityPage): Int {
|
||||
val p = other.priority.compareTo(priority)
|
||||
return if (p != 0) p else identifier.compareTo(other.identifier)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the page with the downloaded image.
|
||||
*
|
||||
* @param page the page whose source image has to be downloaded.
|
||||
*/
|
||||
private fun HttpSource.fetchImageFromCacheThenNet(page: ReaderPage): Observable<ReaderPage> {
|
||||
return if (page.imageUrl.isNullOrEmpty())
|
||||
getImageUrl(page).flatMap { getCachedImage(it) }
|
||||
else
|
||||
getCachedImage(page)
|
||||
}
|
||||
|
||||
private fun HttpSource.getImageUrl(page: ReaderPage): Observable<ReaderPage> {
|
||||
page.status = Page.LOAD_PAGE
|
||||
return fetchImageUrl(page)
|
||||
.doOnError { page.status = Page.ERROR }
|
||||
.onErrorReturn { null }
|
||||
.doOnNext { page.imageUrl = it }
|
||||
.map { page }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the page that gets the image from the chapter or fallbacks to
|
||||
* network and copies it to the cache calling [cacheImage].
|
||||
*
|
||||
* @param page the page.
|
||||
*/
|
||||
private fun HttpSource.getCachedImage(page: ReaderPage): Observable<ReaderPage> {
|
||||
val imageUrl = page.imageUrl ?: return Observable.just(page)
|
||||
|
||||
return Observable.just(page)
|
||||
.flatMap {
|
||||
if (!chapterCache.isImageInCache(imageUrl)) {
|
||||
cacheImage(page)
|
||||
} else {
|
||||
Observable.just(page)
|
||||
}
|
||||
}
|
||||
.doOnNext {
|
||||
page.stream = { chapterCache.getImageFile(imageUrl).inputStream() }
|
||||
page.status = Page.READY
|
||||
}
|
||||
.doOnError { page.status = Page.ERROR }
|
||||
.onErrorReturn { page }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the page that downloads the image to [ChapterCache].
|
||||
*
|
||||
* @param page the page.
|
||||
*/
|
||||
private fun HttpSource.cacheImage(page: ReaderPage): Observable<ReaderPage> {
|
||||
page.status = Page.DOWNLOAD_IMAGE
|
||||
return fetchImage(page)
|
||||
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
|
||||
.map { page }
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import android.support.annotation.CallSuper
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import rx.Observable
|
||||
|
||||
/**
|
||||
* A loader used to load pages into the reader. Any open resources must be cleaned up when the
|
||||
* method [recycle] is called.
|
||||
*/
|
||||
abstract class PageLoader {
|
||||
|
||||
/**
|
||||
* Whether this loader has been already recycled.
|
||||
*/
|
||||
var isRecycled = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Recycles this loader. Implementations must override this method to clean up any active
|
||||
* resources.
|
||||
*/
|
||||
@CallSuper
|
||||
open fun recycle() {
|
||||
isRecycled = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable containing the list of pages of a chapter. Only the first emission
|
||||
* will be used.
|
||||
*/
|
||||
abstract fun getPages(): Observable<List<ReaderPage>>
|
||||
|
||||
/**
|
||||
* Returns an observable that should inform of the progress of the page (see the Page class
|
||||
* for the available states)
|
||||
*/
|
||||
abstract fun getPage(page: ReaderPage): Observable<Int>
|
||||
|
||||
/**
|
||||
* Retries the given [page] in case it failed to load. This method only makes sense when an
|
||||
* online source is used.
|
||||
*/
|
||||
open fun retryPage(page: ReaderPage) {}
|
||||
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.util.ImageUtil
|
||||
import junrar.Archive
|
||||
import junrar.rarfile.FileHeader
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import rx.Observable
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.PipedInputStream
|
||||
import java.io.PipedOutputStream
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Loader used to load a chapter from a .rar or .cbr file.
|
||||
*/
|
||||
class RarPageLoader(file: File) : PageLoader() {
|
||||
|
||||
/**
|
||||
* The rar archive to load pages from.
|
||||
*/
|
||||
private val archive = Archive(file)
|
||||
|
||||
/**
|
||||
* Pool for copying compressed files to an input stream.
|
||||
*/
|
||||
private val pool = Executors.newFixedThreadPool(1)
|
||||
|
||||
/**
|
||||
* Recycles this loader and the open archive.
|
||||
*/
|
||||
override fun recycle() {
|
||||
super.recycle()
|
||||
archive.close()
|
||||
pool.shutdown()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable containing the pages found on this rar archive ordered with a natural
|
||||
* comparator.
|
||||
*/
|
||||
override fun getPages(): Observable<List<ReaderPage>> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
|
||||
return archive.fileHeaders
|
||||
.filter { !it.isDirectory && ImageUtil.isImage(it.fileNameString) { archive.getInputStream(it) } }
|
||||
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
|
||||
.mapIndexed { i, header ->
|
||||
val streamFn = { getStream(header) }
|
||||
|
||||
ReaderPage(i).apply {
|
||||
stream = streamFn
|
||||
status = Page.READY
|
||||
}
|
||||
}
|
||||
.let { Observable.just(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that emits a ready state unless the loader was recycled.
|
||||
*/
|
||||
override fun getPage(page: ReaderPage): Observable<Int> {
|
||||
return Observable.just(if (isRecycled) {
|
||||
Page.ERROR
|
||||
} else {
|
||||
Page.READY
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an input stream for the given [header].
|
||||
*/
|
||||
private fun getStream(header: FileHeader): InputStream {
|
||||
val pipeIn = PipedInputStream()
|
||||
val pipeOut = PipedOutputStream(pipeIn)
|
||||
pool.execute {
|
||||
try {
|
||||
pipeOut.use {
|
||||
archive.extractFile(header, it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
return pipeIn
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.util.ImageUtil
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import rx.Observable
|
||||
import java.io.File
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
/**
|
||||
* Loader used to load a chapter from a .zip or .cbz file.
|
||||
*/
|
||||
class ZipPageLoader(file: File) : PageLoader() {
|
||||
|
||||
/**
|
||||
* The zip file to load pages from.
|
||||
*/
|
||||
private val zip = ZipFile(file)
|
||||
|
||||
/**
|
||||
* Recycles this loader and the open zip.
|
||||
*/
|
||||
override fun recycle() {
|
||||
super.recycle()
|
||||
zip.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable containing the pages found on this zip archive ordered with a natural
|
||||
* comparator.
|
||||
*/
|
||||
override fun getPages(): Observable<List<ReaderPage>> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
|
||||
return zip.entries().toList()
|
||||
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
|
||||
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||
.mapIndexed { i, entry ->
|
||||
val streamFn = { zip.getInputStream(entry) }
|
||||
ReaderPage(i).apply {
|
||||
stream = streamFn
|
||||
status = Page.READY
|
||||
}
|
||||
}
|
||||
.let { Observable.just(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that emits a ready state unless the loader was recycled.
|
||||
*/
|
||||
override fun getPage(page: ReaderPage): Observable<Int> {
|
||||
return Observable.just(if (isRecycled) {
|
||||
Page.ERROR
|
||||
} else {
|
||||
Page.READY
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.model
|
||||
|
||||
sealed class ChapterTransition {
|
||||
|
||||
abstract val from: ReaderChapter
|
||||
abstract val to: ReaderChapter?
|
||||
|
||||
class Prev(
|
||||
override val from: ReaderChapter, override val to: ReaderChapter?
|
||||
) : ChapterTransition()
|
||||
class Next(
|
||||
override val from: ReaderChapter, override val to: ReaderChapter?
|
||||
) : ChapterTransition()
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is ChapterTransition) return false
|
||||
if (from == other.from && to == other.to) return true
|
||||
if (from == other.to && to == other.from) return true
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = from.hashCode()
|
||||
result = 31 * result + (to?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "${javaClass.simpleName}(from=${from.chapter.url}, to=${to?.chapter?.url})"
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.model
|
||||
|
||||
import com.jakewharton.rxrelay.BehaviorRelay
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
|
||||
import timber.log.Timber
|
||||
|
||||
data class ReaderChapter(val chapter: Chapter) {
|
||||
|
||||
var state: State =
|
||||
State.Wait
|
||||
set(value) {
|
||||
field = value
|
||||
stateRelay.call(value)
|
||||
}
|
||||
|
||||
private val stateRelay by lazy { BehaviorRelay.create(state) }
|
||||
|
||||
val stateObserver by lazy { stateRelay.asObservable() }
|
||||
|
||||
val pages: List<ReaderPage>?
|
||||
get() = (state as? State.Loaded)?.pages
|
||||
|
||||
var pageLoader: PageLoader? = null
|
||||
|
||||
var requestedPage: Int = 0
|
||||
|
||||
var references = 0
|
||||
private set
|
||||
|
||||
fun ref() {
|
||||
references++
|
||||
}
|
||||
|
||||
fun unref() {
|
||||
references--
|
||||
if (references == 0) {
|
||||
if (pageLoader != null) {
|
||||
Timber.d("Recycling chapter ${chapter.name}")
|
||||
}
|
||||
pageLoader?.recycle()
|
||||
pageLoader = null
|
||||
state = State.Wait
|
||||
}
|
||||
}
|
||||
|
||||
sealed class State {
|
||||
object Wait : State()
|
||||
object Loading : State()
|
||||
class Error(val error: Throwable) : State()
|
||||
class Loaded(val pages: List<ReaderPage>) : State()
|
||||
}
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.model
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import java.io.InputStream
|
||||
|
||||
class ReaderPage(
|
||||
index: Int,
|
||||
url: String = "",
|
||||
imageUrl: String? = null,
|
||||
var stream: (() -> InputStream)? = null
|
||||
) : Page(index, url, imageUrl, null) {
|
||||
|
||||
lateinit var chapter: ReaderChapter
|
||||
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.model
|
||||
|
||||
data class ViewerChapters(
|
||||
val currChapter: ReaderChapter,
|
||||
val prevChapter: ReaderChapter?,
|
||||
val nextChapter: ReaderChapter?
|
||||
) {
|
||||
|
||||
fun ref() {
|
||||
currChapter.ref()
|
||||
prevChapter?.ref()
|
||||
nextChapter?.ref()
|
||||
}
|
||||
|
||||
fun unref() {
|
||||
currChapter.unref()
|
||||
prevChapter?.unref()
|
||||
nextChapter?.unref()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
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.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
|
||||
/**
|
||||
* Interface for implementing a viewer.
|
||||
*/
|
||||
interface BaseViewer {
|
||||
|
||||
/**
|
||||
* 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 [chapters] as active.
|
||||
*/
|
||||
fun setChapters(chapters: ViewerChapters)
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewConfiguration
|
||||
|
||||
/**
|
||||
* 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()
|
||||
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 (Math.abs(ev.rawX - downX) > slop || Math.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) {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,212 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.view.animation.RotateAnimation
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.util.getResourceColor
|
||||
|
||||
/**
|
||||
* A custom progress bar that always rotates while being determinate. By always rotating we give
|
||||
* the feedback to the user that the application isn't 'stuck', and by making it determinate the
|
||||
* user also approximately knows how much the operation will take.
|
||||
*/
|
||||
class ReaderProgressBar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
/**
|
||||
* The current sweep angle. It always starts at 10% because otherwise the bar and the rotation
|
||||
* wouldn't be visible.
|
||||
*/
|
||||
private var sweepAngle = 10f
|
||||
|
||||
/**
|
||||
* Whether the parent views are also visible.
|
||||
*/
|
||||
private var aggregatedIsVisible = false
|
||||
|
||||
/**
|
||||
* The paint to use to draw the progress bar.
|
||||
*/
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = context.getResourceColor(R.attr.colorAccent)
|
||||
isAntiAlias = true
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
style = Paint.Style.STROKE
|
||||
}
|
||||
|
||||
/**
|
||||
* The rectangle of the canvas where the progress bar should be drawn. This is calculated on
|
||||
* layout.
|
||||
*/
|
||||
private val ovalRect = RectF()
|
||||
|
||||
/**
|
||||
* The rotation animation to use while the progress bar is visible.
|
||||
*/
|
||||
private val rotationAnimation by lazy {
|
||||
RotateAnimation(0f, 360f,
|
||||
Animation.RELATIVE_TO_SELF, 0.5f,
|
||||
Animation.RELATIVE_TO_SELF, 0.5f
|
||||
).apply {
|
||||
interpolator = LinearInterpolator()
|
||||
repeatCount = Animation.INFINITE
|
||||
duration = 4000
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the view is layout. The position and thickness of the progress bar is calculated.
|
||||
*/
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
|
||||
val diameter = Math.min(width, height)
|
||||
val thickness = diameter / 10f
|
||||
val pad = thickness / 2f
|
||||
ovalRect.set(pad, pad, diameter - pad, diameter - pad)
|
||||
|
||||
paint.strokeWidth = thickness
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the view is being drawn. An arc is drawn with the calculated rectangle. The
|
||||
* animation will take care of rotation.
|
||||
*/
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
canvas.drawArc(ovalRect, -90f, sweepAngle, false, paint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the sweep angle to use from the progress.
|
||||
*/
|
||||
private fun calcSweepAngleFromProgress(progress: Int): Float {
|
||||
return 360f / 100 * progress
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when this view is attached to window. It starts the rotation animation.
|
||||
*/
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
startAnimation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when this view is detached to window. It stops the rotation animation.
|
||||
*/
|
||||
override fun onDetachedFromWindow() {
|
||||
stopAnimation()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the visibility of this view changes.
|
||||
*/
|
||||
override fun setVisibility(visibility: Int) {
|
||||
super.setVisibility(visibility)
|
||||
val isVisible = visibility == View.VISIBLE
|
||||
if (isVisible) {
|
||||
startAnimation()
|
||||
} else {
|
||||
stopAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the rotation animation if needed.
|
||||
*/
|
||||
private fun startAnimation() {
|
||||
if (visibility != View.VISIBLE || windowVisibility != View.VISIBLE || animation != null) {
|
||||
return
|
||||
}
|
||||
|
||||
animation = rotationAnimation
|
||||
animation.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the rotation animation if needed.
|
||||
*/
|
||||
private fun stopAnimation() {
|
||||
clearAnimation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides this progress bar with an optional fade out if [animate] is true.
|
||||
*/
|
||||
fun hide(animate: Boolean = false) {
|
||||
if (visibility == View.GONE) return
|
||||
|
||||
if (!animate) {
|
||||
visibility = View.GONE
|
||||
} else {
|
||||
ObjectAnimator.ofFloat(this, "alpha", 1f, 0f).apply {
|
||||
interpolator = DecelerateInterpolator()
|
||||
duration = 1000
|
||||
addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator?) {
|
||||
visibility = View.GONE
|
||||
alpha = 1f
|
||||
}
|
||||
|
||||
override fun onAnimationCancel(animation: Animator?) {
|
||||
alpha = 1f
|
||||
}
|
||||
})
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes this progress bar and fades out the view.
|
||||
*/
|
||||
fun completeAndFadeOut() {
|
||||
setRealProgress(100)
|
||||
hide(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set progress of the circular progress bar ensuring a min max range in order to notice the
|
||||
* rotation animation.
|
||||
*/
|
||||
fun setProgress(progress: Int) {
|
||||
// Scale progress in [10, 95] range
|
||||
val scaledProgress = 85 * progress / 100 + 10
|
||||
setRealProgress(scaledProgress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the real progress of the circular progress bar. Note that if this progres is 0 or
|
||||
* 100, the rotation animation won't be noticed by the user because nothing changes in the
|
||||
* canvas.
|
||||
*/
|
||||
private fun setRealProgress(progress: Int) {
|
||||
ValueAnimator.ofFloat(sweepAngle, calcSweepAngleFromProgress(progress)).apply {
|
||||
interpolator = DecelerateInterpolator()
|
||||
duration = 250
|
||||
addUpdateListener { valueAnimator ->
|
||||
sweepAngle = valueAnimator.animatedValue as Float
|
||||
invalidate()
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,253 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.base
|
||||
|
||||
import android.support.v4.app.Fragment
|
||||
import com.davemorrissey.labs.subscaleview.decoder.*
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Base reader containing the common data that can be used by its implementations. It does not
|
||||
* contain any UI related action.
|
||||
*/
|
||||
abstract class BaseReader : Fragment() {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Image decoder.
|
||||
*/
|
||||
const val IMAGE_DECODER = 0
|
||||
|
||||
/**
|
||||
* Rapid decoder.
|
||||
*/
|
||||
const val RAPID_DECODER = 1
|
||||
|
||||
/**
|
||||
* Skia decoder.
|
||||
*/
|
||||
const val SKIA_DECODER = 2
|
||||
}
|
||||
|
||||
/**
|
||||
* List of chapters added in the reader.
|
||||
*/
|
||||
val chapters = ArrayList<ReaderChapter>()
|
||||
|
||||
/**
|
||||
* List of pages added in the reader. It can contain pages from more than one chapter.
|
||||
*/
|
||||
var pages: MutableList<Page> = ArrayList()
|
||||
private set
|
||||
|
||||
/**
|
||||
* Current visible position of [pages].
|
||||
*/
|
||||
var currentPage: Int = 0
|
||||
protected set
|
||||
|
||||
/**
|
||||
* Region decoder class to use.
|
||||
*/
|
||||
lateinit var regionDecoderClass: Class<out ImageRegionDecoder>
|
||||
private set
|
||||
|
||||
/**
|
||||
* Bitmap decoder class to use.
|
||||
*/
|
||||
lateinit var bitmapDecoderClass: Class<out ImageDecoder>
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether tap navigation is enabled or not.
|
||||
*/
|
||||
val tappingEnabled by lazy { readerActivity.preferences.readWithTapping().getOrDefault() }
|
||||
|
||||
/**
|
||||
* Whether the reader has requested to append a chapter. Used with seamless mode to avoid
|
||||
* restarting requests when changing pages.
|
||||
*/
|
||||
private var hasRequestedNextChapter: Boolean = false
|
||||
|
||||
/**
|
||||
* Returns the active page.
|
||||
*/
|
||||
fun getActivePage(): Page? {
|
||||
return pages.getOrNull(currentPage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a page changes. Implementations must call this method.
|
||||
*
|
||||
* @param position the new current page.
|
||||
*/
|
||||
fun onPageChanged(position: Int) {
|
||||
val oldPage = pages[currentPage]
|
||||
val newPage = pages[position]
|
||||
|
||||
val oldChapter = oldPage.chapter
|
||||
val newChapter = newPage.chapter
|
||||
|
||||
// Update page indicator and seekbar
|
||||
readerActivity.onPageChanged(newPage)
|
||||
|
||||
// Active chapter has changed.
|
||||
if (oldChapter.id != newChapter.id) {
|
||||
readerActivity.onEnterChapter(newPage.chapter, newPage.index)
|
||||
}
|
||||
// Request next chapter only when the conditions are met.
|
||||
if (pages.size - position < 5 && chapters.last().id == newChapter.id
|
||||
&& readerActivity.presenter.hasNextChapter() && !hasRequestedNextChapter) {
|
||||
hasRequestedNextChapter = true
|
||||
readerActivity.presenter.appendNextChapter()
|
||||
}
|
||||
|
||||
currentPage = position
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active page.
|
||||
*
|
||||
* @param page the page to display.
|
||||
*/
|
||||
fun setActivePage(page: Page) {
|
||||
setActivePage(getPageIndex(page))
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchs for the index of a page in the current list without requiring them to be the same
|
||||
* object.
|
||||
*
|
||||
* @param search the page to search.
|
||||
* @return the index of the page in [pages] or 0 if it's not found.
|
||||
*/
|
||||
fun getPageIndex(search: Page): Int {
|
||||
for ((index, page) in pages.withIndex()) {
|
||||
if (page.index == search.index && page.chapter.id == search.chapter.id) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when the page list of a chapter is ready. This method is called
|
||||
* on every [onResume], so we add some logic to avoid duplicating chapters.
|
||||
*
|
||||
* @param chapter the chapter to set.
|
||||
* @param currentPage the initial page to display.
|
||||
*/
|
||||
fun onPageListReady(chapter: ReaderChapter, currentPage: Page) {
|
||||
if (!chapters.contains(chapter)) {
|
||||
// if we reset the loaded page we also need to reset the loaded chapters
|
||||
chapters.clear()
|
||||
chapters.add(chapter)
|
||||
pages = ArrayList(chapter.pages)
|
||||
onChapterSet(chapter, currentPage)
|
||||
} else {
|
||||
setActivePage(currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when the page list of a chapter to append is ready. This method is
|
||||
* called on every [onResume], so we add some logic to avoid duplicating chapters.
|
||||
*
|
||||
* @param chapter the chapter to append.
|
||||
*/
|
||||
fun onPageListAppendReady(chapter: ReaderChapter) {
|
||||
if (!chapters.contains(chapter)) {
|
||||
hasRequestedNextChapter = false
|
||||
chapters.add(chapter)
|
||||
pages.addAll(chapter.pages!!)
|
||||
onChapterAppended(chapter)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active page.
|
||||
*
|
||||
* @param pageNumber the index of the page from [pages].
|
||||
*/
|
||||
abstract fun setActivePage(pageNumber: Int)
|
||||
|
||||
/**
|
||||
* Called when a new chapter is set in [BaseReader].
|
||||
*
|
||||
* @param chapter the chapter set.
|
||||
* @param currentPage the initial page to display.
|
||||
*/
|
||||
abstract fun onChapterSet(chapter: ReaderChapter, currentPage: Page)
|
||||
|
||||
/**
|
||||
* Called when a chapter is appended in [BaseReader].
|
||||
*
|
||||
* @param chapter the chapter appended.
|
||||
*/
|
||||
abstract fun onChapterAppended(chapter: ReaderChapter)
|
||||
|
||||
/**
|
||||
* Moves pages to right. Implementations decide how to move (by a page, by some distance...).
|
||||
*/
|
||||
abstract fun moveRight()
|
||||
|
||||
/**
|
||||
* Moves pages to left. Implementations decide how to move (by a page, by some distance...).
|
||||
*/
|
||||
abstract fun moveLeft()
|
||||
|
||||
/**
|
||||
* Moves pages down. Implementations decide how to move (by a page, by some distance...).
|
||||
*/
|
||||
open fun moveDown() {
|
||||
moveRight()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves pages up. Implementations decide how to move (by a page, by some distance...).
|
||||
*/
|
||||
open fun moveUp() {
|
||||
moveLeft()
|
||||
}
|
||||
|
||||
/**
|
||||
* Method the implementations can call to show a menu with options for the given page.
|
||||
*/
|
||||
fun onLongClick(page: Page?): Boolean {
|
||||
if (isAdded && page != null) {
|
||||
readerActivity.onLongClick(page)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active decoder class.
|
||||
*
|
||||
* @param value the decoder class to use.
|
||||
*/
|
||||
fun setDecoderClass(value: Int) {
|
||||
when (value) {
|
||||
IMAGE_DECODER -> {
|
||||
bitmapDecoderClass = IImageDecoder::class.java
|
||||
regionDecoderClass = IImageRegionDecoder::class.java
|
||||
}
|
||||
RAPID_DECODER -> {
|
||||
bitmapDecoderClass = RapidImageDecoder::class.java
|
||||
regionDecoderClass = RapidImageRegionDecoder::class.java
|
||||
}
|
||||
SKIA_DECODER -> {
|
||||
bitmapDecoderClass = SkiaImageDecoder::class.java
|
||||
regionDecoderClass = SkiaImageRegionDecoder::class.java
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Property to get the reader activity.
|
||||
*/
|
||||
val readerActivity: ReaderActivity
|
||||
get() = activity as ReaderActivity
|
||||
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.base
|
||||
|
||||
import android.net.Uri
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.view.View
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import kotlinx.android.synthetic.main.reader_page_decode_error.view.*
|
||||
|
||||
class PageDecodeErrorLayout(
|
||||
val view: View,
|
||||
val page: Page,
|
||||
val theme: Int,
|
||||
val retryListener: () -> Unit
|
||||
) {
|
||||
|
||||
init {
|
||||
val textColor = if (theme == ReaderActivity.BLACK_THEME)
|
||||
ContextCompat.getColor(view.context, R.color.textColorSecondaryDark)
|
||||
else
|
||||
ContextCompat.getColor(view.context, R.color.textColorSecondaryLight)
|
||||
|
||||
view.decode_error_text.setTextColor(textColor)
|
||||
|
||||
view.decode_retry.setOnClickListener {
|
||||
retryListener()
|
||||
}
|
||||
|
||||
view.decode_open_browser.setOnClickListener {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, Uri.parse(page.imageUrl))
|
||||
view.context.startActivity(intent)
|
||||
}
|
||||
|
||||
if (page.imageUrl == null) {
|
||||
view.decode_open_browser.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
interface OnChapterBoundariesOutListener {
|
||||
fun onFirstPageOutEvent()
|
||||
fun onLastPageOutEvent()
|
||||
}
|
@ -1,328 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.PointF
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.base.PageDecodeErrorLayout
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.RightToLeftReader
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.vertical.VerticalReader
|
||||
import eu.kanade.tachiyomi.util.gone
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import eu.kanade.tachiyomi.util.visible
|
||||
import kotlinx.android.synthetic.main.reader_pager_item.view.*
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.subjects.PublishSubject
|
||||
import rx.subjects.SerializedSubject
|
||||
import java.net.URLConnection
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
|
||||
: FrameLayout(context, attrs) {
|
||||
|
||||
/**
|
||||
* Page of a chapter.
|
||||
*/
|
||||
lateinit var page: Page
|
||||
|
||||
/**
|
||||
* Subscription for status changes of the page.
|
||||
*/
|
||||
private var statusSubscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* Subscription for progress changes of the page.
|
||||
*/
|
||||
private var progressSubscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* Layout of decode error.
|
||||
*/
|
||||
private var decodeErrorLayout: View? = null
|
||||
|
||||
fun initialize(reader: PagerReader, page: Page) {
|
||||
val activity = reader.activity as ReaderActivity
|
||||
|
||||
when (activity.readerTheme) {
|
||||
ReaderActivity.BLACK_THEME -> progress_text.setTextColor(reader.whiteColor)
|
||||
ReaderActivity.WHITE_THEME -> progress_text.setTextColor(reader.blackColor)
|
||||
}
|
||||
|
||||
if (reader is RightToLeftReader) {
|
||||
rotation = -180f
|
||||
}
|
||||
|
||||
// --> EH
|
||||
with(gif_view) {
|
||||
setOnTouchListener { _, motionEvent -> reader.gestureDetector.onTouchEvent(motionEvent) }
|
||||
setOnLongClickListener { reader.onLongClick(page) }
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.useWideViewPort = true
|
||||
settings.builtInZoomControls = true
|
||||
settings.displayZoomControls = false
|
||||
settings.setSupportZoom(true)
|
||||
gone()
|
||||
}
|
||||
// <-- EH
|
||||
|
||||
with(image_view) {
|
||||
setMaxTileSize((reader.activity as ReaderActivity).maxBitmapSize)
|
||||
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_FIXED)
|
||||
setDoubleTapZoomDuration(reader.doubleTapAnimDuration.toInt())
|
||||
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
|
||||
setMinimumScaleType(reader.scaleType)
|
||||
setMinimumDpi(90)
|
||||
setMinimumTileDpi(180)
|
||||
setRegionDecoderClass(reader.regionDecoderClass)
|
||||
setBitmapDecoderClass(reader.bitmapDecoderClass)
|
||||
setVerticalScrollingParent(reader is VerticalReader)
|
||||
setCropBorders(reader.cropBorders)
|
||||
setOnTouchListener { _, motionEvent -> reader.gestureDetector.onTouchEvent(motionEvent) }
|
||||
setOnLongClickListener { reader.onLongClick(page) }
|
||||
setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
|
||||
override fun onReady() {
|
||||
onImageDecoded(reader)
|
||||
}
|
||||
|
||||
override fun onImageLoadError(e: Exception) {
|
||||
onImageDecodeError(reader)
|
||||
}
|
||||
})
|
||||
// --> EH
|
||||
visible()
|
||||
// <-- EH
|
||||
}
|
||||
|
||||
retry_button.setOnTouchListener { _, event ->
|
||||
if (event.action == MotionEvent.ACTION_UP) {
|
||||
activity.presenter.retryPage(page)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
this.page = page
|
||||
observeStatus()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
unsubscribeProgress()
|
||||
unsubscribeStatus()
|
||||
image_view.setOnTouchListener(null)
|
||||
image_view.setOnImageEventListener(null)
|
||||
// --> EH
|
||||
gif_view.setOnTouchListener(null)
|
||||
// <-- EH
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes the status of the page and notify the changes.
|
||||
*
|
||||
* @see processStatus
|
||||
*/
|
||||
private fun observeStatus() {
|
||||
statusSubscription?.unsubscribe()
|
||||
|
||||
val statusSubject = SerializedSubject(PublishSubject.create<Int>())
|
||||
page.setStatusSubject(statusSubject)
|
||||
|
||||
statusSubscription = statusSubject.startWith(page.status)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { processStatus(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes the progress of the page and updates view.
|
||||
*/
|
||||
private fun observeProgress() {
|
||||
progressSubscription?.unsubscribe()
|
||||
|
||||
progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
|
||||
.map { page.progress }
|
||||
.distinctUntilChanged()
|
||||
.onBackpressureLatest()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { progress ->
|
||||
progress_text.text = if (progress > 0) {
|
||||
context.getString(R.string.download_progress, progress)
|
||||
} else {
|
||||
context.getString(R.string.downloading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the status of the page changes.
|
||||
*
|
||||
* @param status the new status of the page.
|
||||
*/
|
||||
private fun processStatus(status: Int) {
|
||||
when (status) {
|
||||
Page.QUEUE -> setQueued()
|
||||
Page.LOAD_PAGE -> setLoading()
|
||||
Page.DOWNLOAD_IMAGE -> {
|
||||
observeProgress()
|
||||
setDownloading()
|
||||
}
|
||||
Page.READY -> {
|
||||
setImage()
|
||||
unsubscribeProgress()
|
||||
}
|
||||
Page.ERROR -> {
|
||||
setError()
|
||||
unsubscribeProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes from the status subscription.
|
||||
*/
|
||||
private fun unsubscribeStatus() {
|
||||
page.setStatusSubject(null)
|
||||
statusSubscription?.unsubscribe()
|
||||
statusSubscription = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes from the progress subscription.
|
||||
*/
|
||||
private fun unsubscribeProgress() {
|
||||
progressSubscription?.unsubscribe()
|
||||
progressSubscription = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the page is queued.
|
||||
*/
|
||||
private fun setQueued() {
|
||||
progress_container.visibility = View.VISIBLE
|
||||
progress_text.visibility = View.INVISIBLE
|
||||
retry_button.visibility = View.GONE
|
||||
// --> EH
|
||||
gif_view.gone()
|
||||
// <-- EH
|
||||
decodeErrorLayout?.let {
|
||||
removeView(it)
|
||||
decodeErrorLayout = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the page is loading.
|
||||
*/
|
||||
private fun setLoading() {
|
||||
progress_container.visibility = View.VISIBLE
|
||||
progress_text.visibility = View.VISIBLE
|
||||
progress_text.setText(R.string.downloading)
|
||||
// --> EH
|
||||
gif_view.gone()
|
||||
// <-- EH
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the page is downloading.
|
||||
*/
|
||||
private fun setDownloading() {
|
||||
progress_container.visibility = View.VISIBLE
|
||||
progress_text.visibility = View.VISIBLE
|
||||
// --> EH
|
||||
gif_view.gone()
|
||||
// <-- EH
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the page is ready.
|
||||
*/
|
||||
private fun setImage() {
|
||||
val uri = page.uri
|
||||
if (uri == null) {
|
||||
page.status = Page.ERROR
|
||||
return
|
||||
}
|
||||
|
||||
val file = UniFile.fromUri(context, uri)
|
||||
if (!file.exists()) {
|
||||
page.status = Page.ERROR
|
||||
return
|
||||
}
|
||||
|
||||
// --> EH
|
||||
val guessedType = file.openInputStream().buffered().use {
|
||||
URLConnection.guessContentTypeFromStream(it)
|
||||
}
|
||||
// <-- EH
|
||||
|
||||
progress_text.visibility = View.INVISIBLE
|
||||
// --> EH
|
||||
if(guessedType == "image/gif") {
|
||||
gif_view.loadUrl(uri.toString())
|
||||
gif_view.visible()
|
||||
progress_container.gone()
|
||||
image_view.gone()
|
||||
} else {
|
||||
// <-- EH
|
||||
image_view.setImage(ImageSource.uri(file.uri))
|
||||
// --> EH
|
||||
gif_view.gone()
|
||||
}
|
||||
// <-- EH
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the page has an error.
|
||||
*/
|
||||
private fun setError() {
|
||||
progress_container.visibility = View.GONE
|
||||
retry_button.visibility = View.VISIBLE
|
||||
// --> EH
|
||||
gif_view.gone()
|
||||
// <-- EH
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the image is decoded and going to be displayed.
|
||||
*/
|
||||
private fun onImageDecoded(reader: PagerReader) {
|
||||
progress_container.visibility = View.GONE
|
||||
|
||||
with(image_view) {
|
||||
when (reader.zoomType) {
|
||||
PagerReader.ALIGN_LEFT -> setScaleAndCenter(scale, PointF(0f, 0f))
|
||||
PagerReader.ALIGN_RIGHT -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0f))
|
||||
PagerReader.ALIGN_CENTER -> setScaleAndCenter(scale, center.apply { y = 0f })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an image fails to decode.
|
||||
*/
|
||||
private fun onImageDecodeError(reader: PagerReader) {
|
||||
progress_container.visibility = View.GONE
|
||||
|
||||
if (decodeErrorLayout != null || !reader.isAdded) return
|
||||
|
||||
val activity = reader.activity as ReaderActivity
|
||||
|
||||
val layout = inflate(R.layout.reader_page_decode_error)
|
||||
PageDecodeErrorLayout(layout, page, activity.readerTheme, {
|
||||
if (reader.isAdded) {
|
||||
activity.presenter.retryPage(page)
|
||||
}
|
||||
})
|
||||
decodeErrorLayout = layout
|
||||
addView(layout)
|
||||
}
|
||||
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager;
|
||||
|
||||
import android.support.v4.view.PagerAdapter;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import rx.functions.Action1;
|
||||
|
||||
public interface Pager {
|
||||
|
||||
void setId(int id);
|
||||
void setLayoutParams(ViewGroup.LayoutParams layoutParams);
|
||||
|
||||
void setOffscreenPageLimit(int limit);
|
||||
|
||||
int getCurrentItem();
|
||||
void setCurrentItem(int item, boolean smoothScroll);
|
||||
|
||||
int getWidth();
|
||||
int getHeight();
|
||||
|
||||
PagerAdapter getAdapter();
|
||||
void setAdapter(PagerAdapter adapter);
|
||||
|
||||
void setOnChapterBoundariesOutListener(OnChapterBoundariesOutListener listener);
|
||||
|
||||
void setOnPageChangeListener(Action1<Integer> onPageChanged);
|
||||
void clearOnPageChangeListeners();
|
||||
}
|
@ -0,0 +1,108 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.content.Context
|
||||
import android.support.v4.view.DirectionalViewPager
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.GestureDetectorWithLongTap
|
||||
|
||||
/**
|
||||
* Pager implementation that listens for tap and long tap and allows temporarily disabling touch
|
||||
* events in order to work with child views that need to disable touch events on this parent. The
|
||||
* pager can also be declared to be vertical by creating it with [isHorizontal] to false.
|
||||
*/
|
||||
open class Pager(
|
||||
context: Context,
|
||||
isHorizontal: Boolean = true
|
||||
) : DirectionalViewPager(context, isHorizontal) {
|
||||
|
||||
/**
|
||||
* Tap listener function to execute when a tap is detected.
|
||||
*/
|
||||
var tapListener: ((MotionEvent) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Long tap listener function to execute when a long tap is detected.
|
||||
*/
|
||||
var longTapListener: ((MotionEvent) -> Boolean)? = null
|
||||
|
||||
/**
|
||||
* Gesture listener that implements tap and long tap events.
|
||||
*/
|
||||
private val gestureListener = object : GestureDetectorWithLongTap.Listener() {
|
||||
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
|
||||
tapListener?.invoke(ev)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onLongTapConfirmed(ev: MotionEvent) {
|
||||
val listener = longTapListener
|
||||
if (listener != null && listener.invoke(ev)) {
|
||||
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gesture detector which handles motion events.
|
||||
*/
|
||||
private val gestureDetector = GestureDetectorWithLongTap(context, gestureListener)
|
||||
|
||||
/**
|
||||
* Whether the gesture detector is currently enabled.
|
||||
*/
|
||||
private var isGestureDetectorEnabled = true
|
||||
|
||||
/**
|
||||
* Dispatches a touch event.
|
||||
*/
|
||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||
val handled = super.dispatchTouchEvent(ev)
|
||||
if (isGestureDetectorEnabled) {
|
||||
gestureDetector.onTouchEvent(ev)
|
||||
}
|
||||
return handled
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given [ev] should be intercepted. Only used to prevent crashes when child
|
||||
* views manipulate [requestDisallowInterceptTouchEvent].
|
||||
*/
|
||||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
||||
return try {
|
||||
super.onInterceptTouchEvent(ev)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a touch event. Only used to prevent crashes when child views manipulate
|
||||
* [requestDisallowInterceptTouchEvent].
|
||||
*/
|
||||
override fun onTouchEvent(ev: MotionEvent): Boolean {
|
||||
return try {
|
||||
super.onTouchEvent(ev)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the given key event when this pager has focus. Just do nothing because the reader
|
||||
* already dispatches key events to the viewer and has more control than this method.
|
||||
*/
|
||||
override fun executeKeyEvent(event: KeyEvent): Boolean {
|
||||
// Disable viewpager's default key event handling
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables the gesture detector.
|
||||
*/
|
||||
fun setGestureDetectorEnabled(enabled: Boolean) {
|
||||
isGestureDetectorEnabled = enabled
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.support.v7.widget.AppCompatButton
|
||||
import android.view.MotionEvent
|
||||
|
||||
/**
|
||||
* A button class to be used by child views of the pager viewer. All tap gestures are handled by
|
||||
* the pager, but this class disables that behavior to allow clickable buttons.
|
||||
*/
|
||||
@SuppressLint("ViewConstructor")
|
||||
class PagerButton(context: Context, viewer: PagerViewer) : AppCompatButton(context) {
|
||||
|
||||
init {
|
||||
setOnTouchListener { _, event ->
|
||||
viewer.pager.setGestureDetectorEnabled(false)
|
||||
if (event.actionMasked == MotionEvent.ACTION_UP) {
|
||||
viewer.pager.setGestureDetectorEnabled(true)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,113 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import com.f2prateek.rx.preferences.Preference
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.util.addTo
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
/**
|
||||
* Configuration used by pager viewers.
|
||||
*/
|
||||
class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelper = Injekt.get()) {
|
||||
|
||||
private val subscriptions = CompositeSubscription()
|
||||
|
||||
var imagePropertyChangedListener: (() -> Unit)? = null
|
||||
|
||||
var tappingEnabled = true
|
||||
private set
|
||||
|
||||
var longTapEnabled = true
|
||||
private set
|
||||
|
||||
var volumeKeysEnabled = false
|
||||
private set
|
||||
|
||||
var volumeKeysInverted = false
|
||||
private set
|
||||
|
||||
var usePageTransitions = false
|
||||
private set
|
||||
|
||||
var imageScaleType = 1
|
||||
private set
|
||||
|
||||
var imageZoomType = ZoomType.Left
|
||||
private set
|
||||
|
||||
var imageCropBorders = false
|
||||
private set
|
||||
|
||||
var doubleTapAnimDuration = 500
|
||||
private set
|
||||
|
||||
init {
|
||||
preferences.readWithTapping()
|
||||
.register({ tappingEnabled = it })
|
||||
|
||||
preferences.readWithLongTap()
|
||||
.register({ longTapEnabled = it })
|
||||
|
||||
preferences.pageTransitions()
|
||||
.register({ usePageTransitions = it })
|
||||
|
||||
preferences.imageScaleType()
|
||||
.register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
preferences.zoomStart()
|
||||
.register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
preferences.cropBorders()
|
||||
.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
preferences.doubleTapAnimSpeed()
|
||||
.register({ doubleTapAnimDuration = it })
|
||||
|
||||
preferences.readWithVolumeKeys()
|
||||
.register({ volumeKeysEnabled = it })
|
||||
|
||||
preferences.readWithVolumeKeysInverted()
|
||||
.register({ volumeKeysInverted = it })
|
||||
}
|
||||
|
||||
fun unsubscribe() {
|
||||
subscriptions.unsubscribe()
|
||||
}
|
||||
|
||||
private fun <T> Preference<T>.register(
|
||||
valueAssignment: (T) -> Unit,
|
||||
onChanged: (T) -> Unit = {}
|
||||
) {
|
||||
asObservable()
|
||||
.doOnNext(valueAssignment)
|
||||
.skip(1)
|
||||
.distinctUntilChanged()
|
||||
.doOnNext(onChanged)
|
||||
.subscribe()
|
||||
.addTo(subscriptions)
|
||||
}
|
||||
|
||||
private fun zoomTypeFromPreference(value: Int) {
|
||||
imageZoomType = when (value) {
|
||||
// Auto
|
||||
1 -> when (viewer) {
|
||||
is L2RPagerViewer -> ZoomType.Left
|
||||
is R2LPagerViewer -> ZoomType.Right
|
||||
else -> ZoomType.Center
|
||||
}
|
||||
// Left
|
||||
2 -> ZoomType.Left
|
||||
// Right
|
||||
3 -> ZoomType.Right
|
||||
// Center
|
||||
else -> ZoomType.Center
|
||||
}
|
||||
}
|
||||
|
||||
enum class ZoomType {
|
||||
Left, Center, Right
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,467 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.graphics.PointF
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.view.GestureDetector
|
||||
import android.view.Gravity
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.engine.GlideException
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
||||
import com.bumptech.glide.request.RequestListener
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import com.bumptech.glide.request.transition.NoTransition
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.github.chrisbanes.photoview.PhotoView
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.glide.GlideApp
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressBar
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerConfig.ZoomType
|
||||
import eu.kanade.tachiyomi.util.ImageUtil
|
||||
import eu.kanade.tachiyomi.util.dpToPx
|
||||
import eu.kanade.tachiyomi.util.gone
|
||||
import eu.kanade.tachiyomi.util.visible
|
||||
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* View of the ViewPager that contains a page of a chapter.
|
||||
*/
|
||||
@SuppressLint("ViewConstructor")
|
||||
class PagerPageHolder(
|
||||
val viewer: PagerViewer,
|
||||
val page: ReaderPage
|
||||
) : FrameLayout(viewer.activity), ViewPagerAdapter.PositionableView {
|
||||
|
||||
/**
|
||||
* Item that identifies this view. Needed by the adapter to not recreate views.
|
||||
*/
|
||||
override val item
|
||||
get() = page
|
||||
|
||||
/**
|
||||
* Loading progress bar to indicate the current progress.
|
||||
*/
|
||||
private val progressBar = createProgressBar()
|
||||
|
||||
/**
|
||||
* Image view that supports subsampling on zoom.
|
||||
*/
|
||||
private var subsamplingImageView: SubsamplingScaleImageView? = null
|
||||
|
||||
/**
|
||||
* Simple image view only used on GIFs.
|
||||
*/
|
||||
private var imageView: ImageView? = null
|
||||
|
||||
/**
|
||||
* Retry button used to allow retrying.
|
||||
*/
|
||||
private var retryButton: PagerButton? = null
|
||||
|
||||
/**
|
||||
* Error layout to show when the image fails to decode.
|
||||
*/
|
||||
private var decodeErrorLayout: ViewGroup? = null
|
||||
|
||||
/**
|
||||
* Subscription for status changes of the page.
|
||||
*/
|
||||
private var statusSubscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* Subscription for progress changes of the page.
|
||||
*/
|
||||
private var progressSubscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* Subscription used to read the header of the image. This is needed in order to instantiate
|
||||
* the appropiate image view depending if the image is animated (GIF).
|
||||
*/
|
||||
private var readImageHeaderSubscription: Subscription? = null
|
||||
|
||||
init {
|
||||
addView(progressBar)
|
||||
observeStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when this view is detached from the window. Unsubscribes any active subscription.
|
||||
*/
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
unsubscribeProgress()
|
||||
unsubscribeStatus()
|
||||
unsubscribeReadImageHeader()
|
||||
subsamplingImageView?.setOnImageEventListener(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes the status of the page and notify the changes.
|
||||
*
|
||||
* @see processStatus
|
||||
*/
|
||||
private fun observeStatus() {
|
||||
statusSubscription?.unsubscribe()
|
||||
|
||||
val loader = page.chapter.pageLoader ?: return
|
||||
statusSubscription = loader.getPage(page)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { processStatus(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes the progress of the page and updates view.
|
||||
*/
|
||||
private fun observeProgress() {
|
||||
progressSubscription?.unsubscribe()
|
||||
|
||||
progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
|
||||
.map { page.progress }
|
||||
.distinctUntilChanged()
|
||||
.onBackpressureLatest()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { value -> progressBar.setProgress(value) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the status of the page changes.
|
||||
*
|
||||
* @param status the new status of the page.
|
||||
*/
|
||||
private fun processStatus(status: Int) {
|
||||
when (status) {
|
||||
Page.QUEUE -> setQueued()
|
||||
Page.LOAD_PAGE -> setLoading()
|
||||
Page.DOWNLOAD_IMAGE -> {
|
||||
observeProgress()
|
||||
setDownloading()
|
||||
}
|
||||
Page.READY -> {
|
||||
setImage()
|
||||
unsubscribeProgress()
|
||||
}
|
||||
Page.ERROR -> {
|
||||
setError()
|
||||
unsubscribeProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes from the status subscription.
|
||||
*/
|
||||
private fun unsubscribeStatus() {
|
||||
statusSubscription?.unsubscribe()
|
||||
statusSubscription = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes from the progress subscription.
|
||||
*/
|
||||
private fun unsubscribeProgress() {
|
||||
progressSubscription?.unsubscribe()
|
||||
progressSubscription = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes from the read image header subscription.
|
||||
*/
|
||||
private fun unsubscribeReadImageHeader() {
|
||||
readImageHeaderSubscription?.unsubscribe()
|
||||
readImageHeaderSubscription = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the page is queued.
|
||||
*/
|
||||
private fun setQueued() {
|
||||
progressBar.visible()
|
||||
retryButton?.gone()
|
||||
decodeErrorLayout?.gone()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the page is loading.
|
||||
*/
|
||||
private fun setLoading() {
|
||||
progressBar.visible()
|
||||
retryButton?.gone()
|
||||
decodeErrorLayout?.gone()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the page is downloading.
|
||||
*/
|
||||
private fun setDownloading() {
|
||||
progressBar.visible()
|
||||
retryButton?.gone()
|
||||
decodeErrorLayout?.gone()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the page is ready.
|
||||
*/
|
||||
private fun setImage() {
|
||||
progressBar.visible()
|
||||
progressBar.completeAndFadeOut()
|
||||
retryButton?.gone()
|
||||
decodeErrorLayout?.gone()
|
||||
|
||||
unsubscribeReadImageHeader()
|
||||
val streamFn = page.stream ?: return
|
||||
|
||||
var openStream: InputStream? = null
|
||||
readImageHeaderSubscription = Observable
|
||||
.fromCallable {
|
||||
val stream = streamFn().buffered(16)
|
||||
openStream = stream
|
||||
|
||||
ImageUtil.findImageType(stream) == ImageUtil.ImageType.GIF
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext { isAnimated ->
|
||||
if (!isAnimated) {
|
||||
initSubsamplingImageView().setImage(ImageSource.inputStream(openStream!!))
|
||||
} else {
|
||||
initImageView().setImage(openStream!!)
|
||||
}
|
||||
}
|
||||
// Keep the Rx stream alive to close the input stream only when unsubscribed
|
||||
.flatMap { Observable.never<Unit>() }
|
||||
.doOnUnsubscribe { openStream?.close() }
|
||||
.subscribe({}, {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the page has an error.
|
||||
*/
|
||||
private fun setError() {
|
||||
progressBar.gone()
|
||||
initRetryButton().visible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the image is decoded and going to be displayed.
|
||||
*/
|
||||
private fun onImageDecoded() {
|
||||
progressBar.gone()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an image fails to decode.
|
||||
*/
|
||||
private fun onImageDecodeError() {
|
||||
progressBar.gone()
|
||||
initDecodeErrorLayout().visible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new progress bar.
|
||||
*/
|
||||
@SuppressLint("PrivateResource")
|
||||
private fun createProgressBar(): ReaderProgressBar {
|
||||
return ReaderProgressBar(context, null).apply {
|
||||
|
||||
val size = 48.dpToPx
|
||||
layoutParams = FrameLayout.LayoutParams(size, size).apply {
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a subsampling scale view.
|
||||
*/
|
||||
private fun initSubsamplingImageView(): SubsamplingScaleImageView {
|
||||
if (subsamplingImageView != null) return subsamplingImageView!!
|
||||
|
||||
val config = viewer.config
|
||||
|
||||
subsamplingImageView = SubsamplingScaleImageView(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
|
||||
setMaxTileSize(viewer.activity.maxBitmapSize)
|
||||
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_FIXED)
|
||||
setDoubleTapZoomDuration(config.doubleTapAnimDuration)
|
||||
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
|
||||
setMinimumScaleType(config.imageScaleType)
|
||||
setMinimumDpi(90)
|
||||
setMinimumTileDpi(180)
|
||||
setCropBorders(config.imageCropBorders)
|
||||
setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
|
||||
override fun onReady() {
|
||||
when (config.imageZoomType) {
|
||||
ZoomType.Left -> setScaleAndCenter(scale, PointF(0f, 0f))
|
||||
ZoomType.Right -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0f))
|
||||
ZoomType.Center -> setScaleAndCenter(scale, center.also { it?.y = 0f })
|
||||
}
|
||||
onImageDecoded()
|
||||
}
|
||||
|
||||
override fun onImageLoadError(e: Exception) {
|
||||
onImageDecodeError()
|
||||
}
|
||||
})
|
||||
}
|
||||
addView(subsamplingImageView)
|
||||
return subsamplingImageView!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes an image view, used for GIFs.
|
||||
*/
|
||||
private fun initImageView(): ImageView {
|
||||
if (imageView != null) return imageView!!
|
||||
|
||||
imageView = PhotoView(context, null).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
|
||||
adjustViewBounds = true
|
||||
setZoomTransitionDuration(viewer.config.doubleTapAnimDuration)
|
||||
setScaleLevels(1f, 2f, 3f)
|
||||
// Force 2 scale levels on double tap
|
||||
setOnDoubleTapListener(object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onDoubleTap(e: MotionEvent): Boolean {
|
||||
if (scale > 1f) {
|
||||
setScale(1f, e.x, e.y, true)
|
||||
} else {
|
||||
setScale(2f, e.x, e.y, true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
addView(imageView)
|
||||
return imageView!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a button to retry pages.
|
||||
*/
|
||||
private fun initRetryButton(): PagerButton {
|
||||
if (retryButton != null) return retryButton!!
|
||||
|
||||
retryButton = PagerButton(context, viewer).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
setText(R.string.action_retry)
|
||||
setOnClickListener {
|
||||
page.chapter.pageLoader?.retryPage(page)
|
||||
}
|
||||
}
|
||||
addView(retryButton)
|
||||
return retryButton!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a decode error layout.
|
||||
*/
|
||||
private fun initDecodeErrorLayout(): ViewGroup {
|
||||
if (decodeErrorLayout != null) return decodeErrorLayout!!
|
||||
|
||||
val margins = 8.dpToPx
|
||||
|
||||
val decodeLayout = LinearLayout(context).apply {
|
||||
gravity = Gravity.CENTER
|
||||
orientation = LinearLayout.VERTICAL
|
||||
}
|
||||
decodeErrorLayout = decodeLayout
|
||||
|
||||
TextView(context).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
|
||||
setMargins(margins, margins, margins, margins)
|
||||
}
|
||||
gravity = Gravity.CENTER
|
||||
setText(R.string.decode_image_error)
|
||||
|
||||
decodeLayout.addView(this)
|
||||
}
|
||||
|
||||
PagerButton(context, viewer).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
|
||||
setMargins(margins, margins, margins, margins)
|
||||
}
|
||||
setText(R.string.action_retry)
|
||||
setOnClickListener {
|
||||
page.chapter.pageLoader?.retryPage(page)
|
||||
}
|
||||
|
||||
decodeLayout.addView(this)
|
||||
}
|
||||
|
||||
val imageUrl = page.imageUrl
|
||||
if (imageUrl.orEmpty().startsWith("http")) {
|
||||
PagerButton(context, viewer).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
|
||||
setMargins(margins, margins, margins, margins)
|
||||
}
|
||||
setText(R.string.action_open_in_browser)
|
||||
setOnClickListener {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(imageUrl))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
decodeLayout.addView(this)
|
||||
}
|
||||
}
|
||||
|
||||
addView(decodeLayout)
|
||||
return decodeLayout
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension method to set a [stream] into this ImageView.
|
||||
*/
|
||||
private fun ImageView.setImage(stream: InputStream) {
|
||||
GlideApp.with(this)
|
||||
.load(stream)
|
||||
.skipMemoryCache(true)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.transition(DrawableTransitionOptions.with(NoTransition.getFactory()))
|
||||
.listener(object : RequestListener<Drawable> {
|
||||
override fun onLoadFailed(
|
||||
e: GlideException?,
|
||||
model: Any?,
|
||||
target: Target<Drawable>?,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
onImageDecodeError()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onResourceReady(
|
||||
resource: Drawable?,
|
||||
model: Any?,
|
||||
target: Target<Drawable>?,
|
||||
dataSource: DataSource?,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
onImageDecoded()
|
||||
return false
|
||||
}
|
||||
})
|
||||
.into(this)
|
||||
}
|
||||
|
||||
}
|
@ -1,326 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.base.BaseReader
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.LeftToRightReader
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.RightToLeftReader
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
|
||||
/**
|
||||
* Implementation of a reader based on a ViewPager.
|
||||
*/
|
||||
abstract class PagerReader : BaseReader() {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Zoom automatic alignment.
|
||||
*/
|
||||
const val ALIGN_AUTO = 1
|
||||
|
||||
/**
|
||||
* Align to left.
|
||||
*/
|
||||
const val ALIGN_LEFT = 2
|
||||
|
||||
/**
|
||||
* Align to right.
|
||||
*/
|
||||
const val ALIGN_RIGHT = 3
|
||||
|
||||
/**
|
||||
* Align to right.
|
||||
*/
|
||||
const val ALIGN_CENTER = 4
|
||||
|
||||
/**
|
||||
* Left side region of the screen. Used for touch events.
|
||||
*/
|
||||
const val LEFT_REGION = 0.33f
|
||||
|
||||
/**
|
||||
* Right side region of the screen. Used for touch events.
|
||||
*/
|
||||
const val RIGHT_REGION = 0.66f
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic interface of a ViewPager.
|
||||
*/
|
||||
lateinit var pager: Pager
|
||||
private set
|
||||
|
||||
/**
|
||||
* Adapter of the pager.
|
||||
*/
|
||||
lateinit var adapter: PagerReaderAdapter
|
||||
private set
|
||||
|
||||
/**
|
||||
* Gesture detector for touch events.
|
||||
*/
|
||||
val gestureDetector by lazy { GestureDetector(context, ImageGestureListener()) }
|
||||
|
||||
/**
|
||||
* Subscriptions for reader settings.
|
||||
*/
|
||||
var subscriptions: CompositeSubscription? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether transitions are enabled or not.
|
||||
*/
|
||||
var transitions: Boolean = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether to crop image borders.
|
||||
*/
|
||||
var cropBorders: Boolean = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Duration of the double tap animation
|
||||
*/
|
||||
var doubleTapAnimDuration = 500
|
||||
private set
|
||||
|
||||
/**
|
||||
* Scale type (fit width, fit screen, etc).
|
||||
*/
|
||||
var scaleType = 1
|
||||
private set
|
||||
|
||||
/**
|
||||
* Zoom type (start position).
|
||||
*/
|
||||
var zoomType = 1
|
||||
private set
|
||||
|
||||
/**
|
||||
* Text color for black theme.
|
||||
*/
|
||||
val whiteColor by lazy { ContextCompat.getColor(context!!, R.color.textColorSecondaryDark) }
|
||||
|
||||
/**
|
||||
* Text color for white theme.
|
||||
*/
|
||||
val blackColor by lazy { ContextCompat.getColor(context!!, R.color.textColorSecondaryLight) }
|
||||
|
||||
/**
|
||||
* Initializes the pager.
|
||||
*
|
||||
* @param pager the pager to initialize.
|
||||
*/
|
||||
protected fun initializePager(pager: Pager) {
|
||||
adapter = PagerReaderAdapter(this)
|
||||
|
||||
this.pager = pager.apply {
|
||||
setLayoutParams(ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT))
|
||||
setOffscreenPageLimit(1)
|
||||
setId(R.id.reader_pager)
|
||||
setOnChapterBoundariesOutListener(object : OnChapterBoundariesOutListener {
|
||||
override fun onFirstPageOutEvent() {
|
||||
readerActivity.requestPreviousChapter()
|
||||
}
|
||||
|
||||
override fun onLastPageOutEvent() {
|
||||
readerActivity.requestNextChapter()
|
||||
}
|
||||
})
|
||||
setOnPageChangeListener { onPageChanged(it) }
|
||||
}
|
||||
pager.adapter = adapter
|
||||
|
||||
subscriptions = CompositeSubscription().apply {
|
||||
val preferences = readerActivity.preferences
|
||||
|
||||
add(preferences.imageDecoder()
|
||||
.asObservable()
|
||||
.doOnNext { setDecoderClass(it) }
|
||||
.skip(1)
|
||||
.distinctUntilChanged()
|
||||
.subscribe { refreshAdapter() })
|
||||
|
||||
add(preferences.zoomStart()
|
||||
.asObservable()
|
||||
.doOnNext { setZoomStart(it) }
|
||||
.skip(1)
|
||||
.distinctUntilChanged()
|
||||
.subscribe { refreshAdapter() })
|
||||
|
||||
add(preferences.imageScaleType()
|
||||
.asObservable()
|
||||
.doOnNext { scaleType = it }
|
||||
.skip(1)
|
||||
.distinctUntilChanged()
|
||||
.subscribe { refreshAdapter() })
|
||||
|
||||
add(preferences.pageTransitions()
|
||||
.asObservable()
|
||||
.subscribe { transitions = it })
|
||||
|
||||
add(preferences.cropBorders()
|
||||
.asObservable()
|
||||
.doOnNext { cropBorders = it }
|
||||
.skip(1)
|
||||
.distinctUntilChanged()
|
||||
.subscribe { refreshAdapter() })
|
||||
|
||||
add(preferences.doubleTapAnimSpeed()
|
||||
.asObservable()
|
||||
.subscribe { doubleTapAnimDuration = it })
|
||||
}
|
||||
|
||||
setPagesOnAdapter()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
pager.clearOnPageChangeListeners()
|
||||
subscriptions?.unsubscribe()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gesture detector for Subsampling Scale Image View.
|
||||
*/
|
||||
inner class ImageGestureListener : GestureDetector.SimpleOnGestureListener() {
|
||||
|
||||
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
||||
if (isAdded) {
|
||||
val positionX = e.x
|
||||
|
||||
if (positionX < pager.width * LEFT_REGION) {
|
||||
if (tappingEnabled) moveLeft()
|
||||
} else if (positionX > pager.width * RIGHT_REGION) {
|
||||
if (tappingEnabled) moveRight()
|
||||
} else {
|
||||
readerActivity.toggleMenu()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a new chapter is set in [BaseReader].
|
||||
*
|
||||
* @param chapter the chapter set.
|
||||
* @param currentPage the initial page to display.
|
||||
*/
|
||||
override fun onChapterSet(chapter: ReaderChapter, currentPage: Page) {
|
||||
this.currentPage = getPageIndex(currentPage) // we might have a new page object
|
||||
|
||||
// Make sure the view is already initialized.
|
||||
if (view != null) {
|
||||
setPagesOnAdapter()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a chapter is appended in [BaseReader].
|
||||
*
|
||||
* @param chapter the chapter appended.
|
||||
*/
|
||||
override fun onChapterAppended(chapter: ReaderChapter) {
|
||||
// Make sure the view is already initialized.
|
||||
if (view != null) {
|
||||
adapter.pages = pages
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the pages on the adapter.
|
||||
*/
|
||||
protected fun setPagesOnAdapter() {
|
||||
if (pages.isNotEmpty()) {
|
||||
// Prevent a wrong active page when changing chapters with the navigation buttons.
|
||||
val currPage = currentPage
|
||||
adapter.pages = pages
|
||||
currentPage = currPage
|
||||
if (currentPage == pager.currentItem) {
|
||||
onPageChanged(currentPage)
|
||||
} else {
|
||||
setActivePage(currentPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active page.
|
||||
*
|
||||
* @param pageNumber the index of the page from [pages].
|
||||
*/
|
||||
override fun setActivePage(pageNumber: Int) {
|
||||
pager.setCurrentItem(pageNumber, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the adapter.
|
||||
*/
|
||||
private fun refreshAdapter() {
|
||||
pager.adapter = adapter
|
||||
pager.setCurrentItem(currentPage, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a page to the right.
|
||||
*/
|
||||
override fun moveRight() {
|
||||
moveToNext()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a page to the left.
|
||||
*/
|
||||
override fun moveLeft() {
|
||||
moveToPrevious()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the next page or requests the next chapter if it's the last one.
|
||||
*/
|
||||
protected fun moveToNext() {
|
||||
if (pager.currentItem != pager.adapter.count - 1) {
|
||||
pager.setCurrentItem(pager.currentItem + 1, transitions)
|
||||
} else {
|
||||
readerActivity.requestNextChapter()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the previous page or requests the previous chapter if it's the first one.
|
||||
*/
|
||||
protected fun moveToPrevious() {
|
||||
if (pager.currentItem != 0) {
|
||||
pager.setCurrentItem(pager.currentItem - 1, transitions)
|
||||
} else {
|
||||
readerActivity.requestPreviousChapter()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the zoom start position.
|
||||
*
|
||||
* @param zoomStart the value stored in preferences.
|
||||
*/
|
||||
private fun setZoomStart(zoomStart: Int) {
|
||||
if (zoomStart == ALIGN_AUTO) {
|
||||
if (this is LeftToRightReader)
|
||||
setZoomStart(ALIGN_LEFT)
|
||||
else if (this is RightToLeftReader)
|
||||
setZoomStart(ALIGN_RIGHT)
|
||||
else
|
||||
setZoomStart(ALIGN_CENTER)
|
||||
} else {
|
||||
zoomType = zoomStart
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.support.v4.view.PagerAdapter
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
|
||||
|
||||
/**
|
||||
* Adapter of pages for a ViewPager.
|
||||
*/
|
||||
class PagerReaderAdapter(private val reader: PagerReader) : ViewPagerAdapter() {
|
||||
|
||||
/**
|
||||
* Pages stored in the adapter.
|
||||
*/
|
||||
var pages: List<Page> = emptyList()
|
||||
set(value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun createView(container: ViewGroup, position: Int): View {
|
||||
val view = container.inflate(R.layout.reader_pager_item) as PageView
|
||||
view.initialize(reader, pages[position])
|
||||
return view
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of pages.
|
||||
*/
|
||||
override fun getCount(): Int {
|
||||
return pages.size
|
||||
}
|
||||
|
||||
override fun getItemPosition(obj: Any): Int {
|
||||
val view = obj as PageView
|
||||
return if (view.page in pages) {
|
||||
PagerAdapter.POSITION_UNCHANGED
|
||||
} else {
|
||||
PagerAdapter.POSITION_NONE
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,207 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Typeface
|
||||
import android.support.v7.widget.AppCompatTextView
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.util.dpToPx
|
||||
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
|
||||
/**
|
||||
* View of the ViewPager that contains a chapter transition.
|
||||
*/
|
||||
@SuppressLint("ViewConstructor")
|
||||
class PagerTransitionHolder(
|
||||
val viewer: PagerViewer,
|
||||
val transition: ChapterTransition
|
||||
) : LinearLayout(viewer.activity), ViewPagerAdapter.PositionableView {
|
||||
|
||||
/**
|
||||
* Item that identifies this view. Needed by the adapter to not recreate views.
|
||||
*/
|
||||
override val item: Any
|
||||
get() = transition
|
||||
|
||||
/**
|
||||
* Subscription for status changes of the transition page.
|
||||
*/
|
||||
private var statusSubscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* Text view used to display the text of the current and next/prev chapters.
|
||||
*/
|
||||
private var textView = TextView(context).apply {
|
||||
wrapContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* View container of the current status of the transition page. Child views will be added
|
||||
* dynamically.
|
||||
*/
|
||||
private var pagesContainer = LinearLayout(context).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||
orientation = VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
val sidePadding = 64.dpToPx
|
||||
setPadding(sidePadding, 0, sidePadding, 0)
|
||||
addView(textView)
|
||||
addView(pagesContainer)
|
||||
|
||||
when (transition) {
|
||||
is ChapterTransition.Prev -> bindPrevChapterTransition()
|
||||
is ChapterTransition.Next -> bindNextChapterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when this view is detached from the window. Unsubscribes any active subscription.
|
||||
*/
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
statusSubscription?.unsubscribe()
|
||||
statusSubscription = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a next chapter transition on this view and subscribes to the load status.
|
||||
*/
|
||||
private fun bindNextChapterTransition() {
|
||||
val nextChapter = transition.to
|
||||
|
||||
textView.text = if (nextChapter != null) {
|
||||
SpannableStringBuilder().apply {
|
||||
append(context.getString(R.string.transition_finished))
|
||||
setSpan(StyleSpan(Typeface.BOLD), 0, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
|
||||
append("\n${transition.from.chapter.name}\n\n")
|
||||
val currSize = length
|
||||
append(context.getString(R.string.transition_next))
|
||||
setSpan(StyleSpan(Typeface.BOLD), currSize, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
|
||||
append("\n${nextChapter.chapter.name}\n\n")
|
||||
}
|
||||
} else {
|
||||
context.getString(R.string.transition_no_next)
|
||||
}
|
||||
|
||||
if (nextChapter != null) {
|
||||
observeStatus(nextChapter)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a previous chapter transition on this view and subscribes to the page load status.
|
||||
*/
|
||||
private fun bindPrevChapterTransition() {
|
||||
val prevChapter = transition.to
|
||||
|
||||
textView.text = if (prevChapter != null) {
|
||||
SpannableStringBuilder().apply {
|
||||
append(context.getString(R.string.transition_current))
|
||||
setSpan(StyleSpan(Typeface.BOLD), 0, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
|
||||
append("\n${transition.from.chapter.name}\n\n")
|
||||
val currSize = length
|
||||
append(context.getString(R.string.transition_previous))
|
||||
setSpan(StyleSpan(Typeface.BOLD), currSize, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
|
||||
append("\n${prevChapter.chapter.name}\n\n")
|
||||
}
|
||||
} else {
|
||||
context.getString(R.string.transition_no_previous)
|
||||
}
|
||||
|
||||
if (prevChapter != null) {
|
||||
observeStatus(prevChapter)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes the status of the page list of the next/previous chapter. Whenever there's a new
|
||||
* state, the pages container is cleaned up before setting the new state.
|
||||
*/
|
||||
private fun observeStatus(chapter: ReaderChapter) {
|
||||
statusSubscription?.unsubscribe()
|
||||
statusSubscription = chapter.stateObserver
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { state ->
|
||||
pagesContainer.removeAllViews()
|
||||
when (state) {
|
||||
is ReaderChapter.State.Wait -> {}
|
||||
is ReaderChapter.State.Loading -> setLoading()
|
||||
is ReaderChapter.State.Error -> setError(state.error)
|
||||
is ReaderChapter.State.Loaded -> setLoaded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the loading state on the pages container.
|
||||
*/
|
||||
private fun setLoading() {
|
||||
val progress = ProgressBar(context, null, android.R.attr.progressBarStyle)
|
||||
|
||||
val textView = AppCompatTextView(context).apply {
|
||||
wrapContent()
|
||||
setText(R.string.transition_pages_loading)
|
||||
}
|
||||
|
||||
pagesContainer.addView(progress)
|
||||
pagesContainer.addView(textView)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the loaded state on the pages container.
|
||||
*/
|
||||
private fun setLoaded() {
|
||||
// No additional view is added
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the error state on the pages container.
|
||||
*/
|
||||
private fun setError(error: Throwable) {
|
||||
val textView = AppCompatTextView(context).apply {
|
||||
wrapContent()
|
||||
text = context.getString(R.string.transition_pages_error, error.message)
|
||||
}
|
||||
|
||||
val retryBtn = PagerButton(context, viewer).apply {
|
||||
wrapContent()
|
||||
setText(R.string.action_retry)
|
||||
setOnClickListener {
|
||||
val toChapter = transition.to
|
||||
if (toChapter != null) {
|
||||
viewer.activity.requestPreloadChapter(toChapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pagesContainer.addView(textView)
|
||||
pagesContainer.addView(retryBtn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension method to set layout params to wrap content on this view.
|
||||
*/
|
||||
private fun View.wrapContent() {
|
||||
layoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,316 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.support.v4.view.ViewPager
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Implementation of a [BaseViewer] to display pages with a [ViewPager].
|
||||
*/
|
||||
@Suppress("LeakingThis")
|
||||
abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
|
||||
|
||||
/**
|
||||
* View pager used by this viewer. It's abstract to implement L2R, R2L and vertical pagers on
|
||||
* top of this class.
|
||||
*/
|
||||
val pager = createPager()
|
||||
|
||||
/**
|
||||
* Configuration used by the pager, like allow taps, scale mode on images, page transitions...
|
||||
*/
|
||||
val config = PagerConfig(this)
|
||||
|
||||
/**
|
||||
* Adapter of the pager.
|
||||
*/
|
||||
private val adapter = PagerViewerAdapter(this)
|
||||
|
||||
/**
|
||||
* Currently active item. It can be a chapter page or a chapter transition.
|
||||
*/
|
||||
private var currentPage: Any? = null
|
||||
|
||||
/**
|
||||
* Viewer chapters to set when the pager enters idle mode. Otherwise, if the view was settling
|
||||
* or dragging, there'd be a noticeable and annoying jump.
|
||||
*/
|
||||
private var awaitingIdleViewerChapters: ViewerChapters? = null
|
||||
|
||||
/**
|
||||
* Whether the view pager is currently in idle mode. It sets the awaiting chapters if setting
|
||||
* this field to true.
|
||||
*/
|
||||
private var isIdle = true
|
||||
set(value) {
|
||||
field = value
|
||||
if (value) {
|
||||
awaitingIdleViewerChapters?.let {
|
||||
setChaptersInternal(it)
|
||||
awaitingIdleViewerChapters = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
pager.visibility = View.GONE // Don't layout the pager yet
|
||||
pager.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
pager.offscreenPageLimit = 1
|
||||
pager.id = R.id.reader_pager
|
||||
pager.adapter = adapter
|
||||
pager.addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
val page = adapter.items.getOrNull(position)
|
||||
if (page != null && currentPage != page) {
|
||||
currentPage = page
|
||||
when (page) {
|
||||
is ReaderPage -> onPageSelected(page, position)
|
||||
is ChapterTransition -> onTransitionSelected(page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageScrollStateChanged(state: Int) {
|
||||
isIdle = state == ViewPager.SCROLL_STATE_IDLE
|
||||
}
|
||||
})
|
||||
pager.tapListener = { event ->
|
||||
val positionX = event.x
|
||||
when {
|
||||
positionX < pager.width * 0.33f -> if (config.tappingEnabled) moveLeft()
|
||||
positionX > pager.width * 0.66f -> if (config.tappingEnabled) moveRight()
|
||||
else -> activity.toggleMenu()
|
||||
}
|
||||
}
|
||||
pager.longTapListener = f@ {
|
||||
if (activity.menuVisible || config.longTapEnabled) {
|
||||
val item = adapter.items.getOrNull(pager.currentItem)
|
||||
if (item is ReaderPage) {
|
||||
activity.onPageLongTap(item)
|
||||
return@f true
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
config.imagePropertyChangedListener = {
|
||||
refreshAdapter()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ViewPager.
|
||||
*/
|
||||
abstract fun createPager(): Pager
|
||||
|
||||
/**
|
||||
* Returns the view this viewer uses.
|
||||
*/
|
||||
override fun getView(): View {
|
||||
return pager
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys this viewer. Called when leaving the reader or swapping viewers.
|
||||
*/
|
||||
override fun destroy() {
|
||||
super.destroy()
|
||||
config.unsubscribe()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the ViewPager listener when a [page] is marked as active. It notifies the
|
||||
* activity of the change and requests the preload of the next chapter if this is the last page.
|
||||
*/
|
||||
private fun onPageSelected(page: ReaderPage, position: Int) {
|
||||
val pages = page.chapter.pages!! // Won't be null because it's the loaded chapter
|
||||
Timber.d("onPageSelected: ${page.number}/${pages.size}")
|
||||
activity.onPageSelected(page)
|
||||
|
||||
if (page === pages.last()) {
|
||||
Timber.d("Request preload next chapter because we're at the last page")
|
||||
val transition = adapter.items.getOrNull(position + 1) as? ChapterTransition.Next
|
||||
if (transition?.to != null) {
|
||||
activity.requestPreloadChapter(transition.to)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the ViewPager listener when a [transition] is marked as active. It request the
|
||||
* preload of the destination chapter of the transition.
|
||||
*/
|
||||
private fun onTransitionSelected(transition: ChapterTransition) {
|
||||
Timber.d("onTransitionSelected: $transition")
|
||||
val toChapter = transition.to
|
||||
if (toChapter != null) {
|
||||
Timber.d("Request preload destination chapter because we're on the transition")
|
||||
activity.requestPreloadChapter(toChapter)
|
||||
} else if (transition is ChapterTransition.Next) {
|
||||
// No more chapters, show menu because the user is probably going to close the reader
|
||||
activity.showMenu()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells this viewer to set the given [chapters] as active. If the pager is currently idle,
|
||||
* it sets the chapters immediately, otherwise they are saved and set when it becomes idle.
|
||||
*/
|
||||
override fun setChapters(chapters: ViewerChapters) {
|
||||
if (isIdle) {
|
||||
setChaptersInternal(chapters)
|
||||
} else {
|
||||
awaitingIdleViewerChapters = chapters
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active [chapters] on this pager.
|
||||
*/
|
||||
private fun setChaptersInternal(chapters: ViewerChapters) {
|
||||
Timber.d("setChaptersInternal")
|
||||
adapter.setChapters(chapters)
|
||||
|
||||
// Layout the pager once a chapter is being set
|
||||
if (pager.visibility == View.GONE) {
|
||||
Timber.d("Pager first layout")
|
||||
val pages = chapters.currChapter.pages ?: return
|
||||
moveToPage(pages[chapters.currChapter.requestedPage])
|
||||
pager.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells this viewer to move to the given [page].
|
||||
*/
|
||||
override fun moveToPage(page: ReaderPage) {
|
||||
Timber.d("moveToPage")
|
||||
val position = adapter.items.indexOf(page)
|
||||
if (position != -1) {
|
||||
pager.setCurrentItem(position, true)
|
||||
} else {
|
||||
Timber.d("Page $page not found in adapter")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the next page.
|
||||
*/
|
||||
open fun moveToNext() {
|
||||
moveRight()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the previous page.
|
||||
*/
|
||||
open fun moveToPrevious() {
|
||||
moveLeft()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the page at the right.
|
||||
*/
|
||||
protected open fun moveRight() {
|
||||
if (pager.currentItem != adapter.count - 1) {
|
||||
pager.setCurrentItem(pager.currentItem + 1, config.usePageTransitions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the page at the left.
|
||||
*/
|
||||
protected open fun moveLeft() {
|
||||
if (pager.currentItem != 0) {
|
||||
pager.setCurrentItem(pager.currentItem - 1, config.usePageTransitions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the page at the top (or previous).
|
||||
*/
|
||||
protected open fun moveUp() {
|
||||
moveToPrevious()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the page at the bottom (or next).
|
||||
*/
|
||||
protected open fun moveDown() {
|
||||
moveToNext()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the adapter in order to recreate all the views. Used when a image configuration is
|
||||
* changed.
|
||||
*/
|
||||
private fun refreshAdapter() {
|
||||
val currentItem = pager.currentItem
|
||||
pager.adapter = adapter
|
||||
pager.setCurrentItem(currentItem, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the containing activity when a key [event] is received. It should return true
|
||||
* if the event was handled, false otherwise.
|
||||
*/
|
||||
override fun handleKeyEvent(event: KeyEvent): Boolean {
|
||||
val isUp = event.action == KeyEvent.ACTION_UP
|
||||
|
||||
when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||
if (!config.volumeKeysEnabled || activity.menuVisible) {
|
||||
return false
|
||||
} else if (isUp) {
|
||||
if (!config.volumeKeysInverted) moveDown() else moveUp()
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> {
|
||||
if (!config.volumeKeysEnabled || activity.menuVisible) {
|
||||
return false
|
||||
} else if (isUp) {
|
||||
if (!config.volumeKeysInverted) moveUp() else moveDown()
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> if (isUp) moveRight()
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> if (isUp) moveLeft()
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> if (isUp) moveDown()
|
||||
KeyEvent.KEYCODE_DPAD_UP -> if (isUp) moveUp()
|
||||
KeyEvent.KEYCODE_PAGE_DOWN -> if (isUp) moveDown()
|
||||
KeyEvent.KEYCODE_PAGE_UP -> if (isUp) moveUp()
|
||||
KeyEvent.KEYCODE_MENU -> if (isUp) activity.toggleMenu()
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the containing activity when a generic motion [event] is received. It should
|
||||
* return true if the event was handled, false otherwise.
|
||||
*/
|
||||
override fun handleGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_SCROLL -> {
|
||||
if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0.0f) {
|
||||
moveDown()
|
||||
} else {
|
||||
moveUp()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,101 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.support.v4.view.PagerAdapter
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Pager adapter used by this [viewer] to where [ViewerChapters] updates are posted.
|
||||
*/
|
||||
class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
||||
|
||||
/**
|
||||
* List of currently set items.
|
||||
*/
|
||||
var items: List<Any> = emptyList()
|
||||
private set
|
||||
|
||||
/**
|
||||
* Updates this adapter with the given [chapters]. It handles setting a few pages of the
|
||||
* next/previous chapter to allow seamless transitions and inverting the pages if the viewer
|
||||
* has R2L direction.
|
||||
*/
|
||||
fun setChapters(chapters: ViewerChapters) {
|
||||
val newItems = mutableListOf<Any>()
|
||||
|
||||
// Add previous chapter pages and transition.
|
||||
if (chapters.prevChapter != null) {
|
||||
// We only need to add the last few pages of the previous chapter, because it'll be
|
||||
// selected as the current chapter when one of those pages is selected.
|
||||
val prevPages = chapters.prevChapter.pages
|
||||
if (prevPages != null) {
|
||||
newItems.addAll(prevPages.takeLast(2))
|
||||
}
|
||||
}
|
||||
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
|
||||
|
||||
// Add current chapter.
|
||||
val currPages = chapters.currChapter.pages
|
||||
if (currPages != null) {
|
||||
newItems.addAll(currPages)
|
||||
}
|
||||
|
||||
// Add next chapter transition and pages.
|
||||
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
|
||||
if (chapters.nextChapter != null) {
|
||||
// Add at most two pages, because this chapter will be selected before the user can
|
||||
// swap more pages.
|
||||
val nextPages = chapters.nextChapter.pages
|
||||
if (nextPages != null) {
|
||||
newItems.addAll(nextPages.take(2))
|
||||
}
|
||||
}
|
||||
|
||||
if (viewer is R2LPagerViewer) {
|
||||
newItems.reverse()
|
||||
}
|
||||
|
||||
items = newItems
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount of items of the adapter.
|
||||
*/
|
||||
override fun getCount(): Int {
|
||||
return items.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new view for the item at the given [position].
|
||||
*/
|
||||
override fun createView(container: ViewGroup, position: Int): View {
|
||||
val item = items[position]
|
||||
return when (item) {
|
||||
is ReaderPage -> PagerPageHolder(viewer, item)
|
||||
is ChapterTransition -> PagerTransitionHolder(viewer, item)
|
||||
else -> throw NotImplementedError("Holder for ${item.javaClass} not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current position of the given [view] on the adapter.
|
||||
*/
|
||||
override fun getItemPosition(view: Any): Int {
|
||||
if (view is PositionableView) {
|
||||
val position = items.indexOf(view.item)
|
||||
if (position != -1) {
|
||||
return position
|
||||
} else {
|
||||
Timber.d("Position for ${view.item} not found")
|
||||
}
|
||||
}
|
||||
return PagerAdapter.POSITION_NONE
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
|
||||
/**
|
||||
* Implementation of a left to right PagerViewer.
|
||||
*/
|
||||
class L2RPagerViewer(activity: ReaderActivity) : PagerViewer(activity) {
|
||||
/**
|
||||
* Creates a new left to right pager.
|
||||
*/
|
||||
override fun createPager(): Pager {
|
||||
return Pager(activity)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of a right to left PagerViewer.
|
||||
*/
|
||||
class R2LPagerViewer(activity: ReaderActivity) : PagerViewer(activity) {
|
||||
/**
|
||||
* Creates a new right to left pager.
|
||||
*/
|
||||
override fun createPager(): Pager {
|
||||
return Pager(activity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the next page. On a R2L pager the next page is the one at the left.
|
||||
*/
|
||||
override fun moveToNext() {
|
||||
moveLeft()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the previous page. On a R2L pager the previous page is the one at the right.
|
||||
*/
|
||||
override fun moveToPrevious() {
|
||||
moveRight()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of a vertical (top to bottom) PagerViewer.
|
||||
*/
|
||||
class VerticalPagerViewer(activity: ReaderActivity) : PagerViewer(activity) {
|
||||
/**
|
||||
* Creates a new vertical pager.
|
||||
*/
|
||||
override fun createPager(): Pager {
|
||||
return Pager(activity, isHorizontal = false)
|
||||
}
|
||||
}
|
@ -1,86 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal
|
||||
|
||||
import android.content.Context
|
||||
import android.support.v4.view.ViewPager
|
||||
import android.view.MotionEvent
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.OnChapterBoundariesOutListener
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.Pager
|
||||
import rx.functions.Action1
|
||||
|
||||
/**
|
||||
* Implementation of a [ViewPager] to add custom behavior on touch events.
|
||||
*/
|
||||
class HorizontalPager(context: Context) : ViewPager(context), Pager {
|
||||
|
||||
companion object {
|
||||
|
||||
const val SWIPE_TOLERANCE = 0.25f
|
||||
}
|
||||
|
||||
private var onChapterBoundariesOutListener: OnChapterBoundariesOutListener? = null
|
||||
|
||||
private var startDragX: Float = 0f
|
||||
|
||||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
||||
try {
|
||||
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_DOWN) {
|
||||
if (currentItem == 0 || currentItem == adapter!!.count - 1) {
|
||||
startDragX = ev.x
|
||||
}
|
||||
}
|
||||
|
||||
return super.onInterceptTouchEvent(ev)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onTouchEvent(ev: MotionEvent): Boolean {
|
||||
try {
|
||||
onChapterBoundariesOutListener?.let { listener ->
|
||||
if (currentItem == 0) {
|
||||
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
|
||||
val displacement = ev.x - startDragX
|
||||
|
||||
if (ev.x > startDragX && displacement > width * SWIPE_TOLERANCE) {
|
||||
listener.onFirstPageOutEvent()
|
||||
return true
|
||||
}
|
||||
|
||||
startDragX = 0f
|
||||
}
|
||||
} else if (currentItem == adapter!!.count - 1) {
|
||||
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
|
||||
val displacement = startDragX - ev.x
|
||||
|
||||
if (ev.x < startDragX && displacement > width * SWIPE_TOLERANCE) {
|
||||
listener.onLastPageOutEvent()
|
||||
return true
|
||||
}
|
||||
|
||||
startDragX = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.onTouchEvent(ev)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun setOnChapterBoundariesOutListener(listener: OnChapterBoundariesOutListener) {
|
||||
onChapterBoundariesOutListener = listener
|
||||
}
|
||||
|
||||
override fun setOnPageChangeListener(func: Action1<Int>) {
|
||||
addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
func.call(position)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerReader
|
||||
|
||||
/**
|
||||
* Left to Right reader.
|
||||
*/
|
||||
class LeftToRightReader : PagerReader() {
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
|
||||
return HorizontalPager(activity!!).apply { initializePager(this) }
|
||||
}
|
||||
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerReader
|
||||
|
||||
/**
|
||||
* Right to Left reader.
|
||||
*/
|
||||
class RightToLeftReader : PagerReader() {
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
|
||||
return HorizontalPager(activity!!).apply {
|
||||
rotation = 180f
|
||||
initializePager(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a page to the right.
|
||||
*/
|
||||
override fun moveRight() {
|
||||
moveToPrevious()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a page to the left.
|
||||
*/
|
||||
override fun moveLeft() {
|
||||
moveToNext()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a page down.
|
||||
*/
|
||||
override fun moveDown() {
|
||||
moveToNext()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a page up.
|
||||
*/
|
||||
override fun moveUp() {
|
||||
moveToPrevious()
|
||||
}
|
||||
|
||||
}
|
@ -1,84 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager.vertical
|
||||
|
||||
import android.content.Context
|
||||
import android.view.MotionEvent
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.OnChapterBoundariesOutListener
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.Pager
|
||||
import rx.functions.Action1
|
||||
|
||||
/**
|
||||
* Implementation of a [VerticalViewPagerImpl] to add custom behavior on touch events.
|
||||
*/
|
||||
class VerticalPager(context: Context) : VerticalViewPagerImpl(context), Pager {
|
||||
|
||||
private var onChapterBoundariesOutListener: OnChapterBoundariesOutListener? = null
|
||||
private var startDragY: Float = 0.toFloat()
|
||||
|
||||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
||||
try {
|
||||
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_DOWN) {
|
||||
if (currentItem == 0 || currentItem == adapter.count - 1) {
|
||||
startDragY = ev.y
|
||||
}
|
||||
}
|
||||
|
||||
return super.onInterceptTouchEvent(ev)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onTouchEvent(ev: MotionEvent): Boolean {
|
||||
try {
|
||||
onChapterBoundariesOutListener?.let { listener ->
|
||||
if (currentItem == 0) {
|
||||
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
|
||||
val displacement = ev.y - startDragY
|
||||
|
||||
if (ev.y > startDragY && displacement > height * SWIPE_TOLERANCE) {
|
||||
listener.onFirstPageOutEvent()
|
||||
return true
|
||||
}
|
||||
|
||||
startDragY = 0f
|
||||
}
|
||||
} else if (currentItem == adapter.count - 1) {
|
||||
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
|
||||
val displacement = startDragY - ev.y
|
||||
|
||||
if (ev.y < startDragY && displacement > height * SWIPE_TOLERANCE) {
|
||||
listener.onLastPageOutEvent()
|
||||
return true
|
||||
}
|
||||
|
||||
startDragY = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.onTouchEvent(ev)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun setOnChapterBoundariesOutListener(listener: OnChapterBoundariesOutListener) {
|
||||
onChapterBoundariesOutListener = listener
|
||||
}
|
||||
|
||||
override fun setOnPageChangeListener(func: Action1<Int>) {
|
||||
addOnPageChangeListener(object : VerticalViewPagerImpl.SimpleOnPageChangeListener() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
func.call(position)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val SWIPE_TOLERANCE = 0.25f
|
||||
}
|
||||
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager.vertical
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerReader
|
||||
|
||||
/**
|
||||
* Vertical reader.
|
||||
*/
|
||||
class VerticalReader : PagerReader() {
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
|
||||
return VerticalPager(activity!!).apply { initializePager(this) }
|
||||
}
|
||||
|
||||
}
|
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user