Upstream merge
This commit is contained in:
commit
5fbe1a8614
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,6 +50,7 @@ open class App : Application() {
|
||||
}
|
||||
|
||||
protected open fun setupJobManager() {
|
||||
try {
|
||||
JobManager.create(this).addJobCreator { tag ->
|
||||
when (tag) {
|
||||
LibraryUpdateJob.TAG -> LibraryUpdateJob()
|
||||
@ -58,6 +59,9 @@ open class App : Application() {
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w("Can't initialize job manager")
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun setupNotificationChannels() {
|
||||
|
@ -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()
|
||||
|
@ -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,
|
||||
class DownloadCache(
|
||||
private val context: Context,
|
||||
private val provider: DownloadProvider,
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val preferences: PreferencesHelper = Injekt.get()) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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))
|
||||
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("entry")) }
|
||||
.filter { it.select("type").text() != "Novel" }
|
||||
.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("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")!!
|
||||
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 getList(username: String): Observable<List<TrackSearch>> {
|
||||
return client
|
||||
.newCall(GET(getListUrl(username), headers))
|
||||
.asObservable()
|
||||
.map { Jsoup.parse(Parser.unescapeEntities(it.body()!!.string(), false), "", Parser.xmlParser()) }
|
||||
.flatMap { Observable.from(it.select("manga")) }
|
||||
.map {
|
||||
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("series_title")!!
|
||||
media_id = it.selectInt("series_mangadb_id")
|
||||
title = it.selectText("manga_title")!!
|
||||
media_id = it.selectInt("manga_mangadb_id")
|
||||
last_chapter_read = it.selectInt("my_read_chapters")
|
||||
status = it.selectInt("my_status")
|
||||
status = getStatus(it.selectText("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)
|
||||
total_chapters = it.selectInt("manga_chapters")
|
||||
tracking_url = mangaUrl(media_id)
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun findLibManga(track: Track, username: String): Observable<Track?> {
|
||||
return getList(username)
|
||||
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))
|
||||
fun login(username: String, password: String): Observable<String> {
|
||||
return getSessionInfo()
|
||||
.flatMap { csrf ->
|
||||
login(username, password, csrf)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSessionInfo(): Observable<String> {
|
||||
return client.newCall(GET(getLoginUrl()))
|
||||
.asObservable()
|
||||
.doOnNext { response ->
|
||||
response.close()
|
||||
if (response.code() != 200) throw Exception("Login error")
|
||||
.map { response ->
|
||||
Jsoup.parse(response.consumeBody())
|
||||
.select("meta[name=csrf_token]")
|
||||
.attr("content")
|
||||
}
|
||||
}
|
||||
|
||||
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 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")
|
||||
}
|
||||
text(STATUS_TAG, track.status.toString())
|
||||
text(SCORE_TAG, track.score.toString())
|
||||
csrf
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLoginPostBody(username: String, password: String, csrf: String): RequestBody {
|
||||
return FormBody.Builder()
|
||||
.add("data", data)
|
||||
.add("user_name", username)
|
||||
.add("password", password)
|
||||
.add("cookie", "1")
|
||||
.add("sublogin", "Login")
|
||||
.add("submit", "1")
|
||||
.add(CSRF, csrf)
|
||||
.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()
|
||||
private fun getExportPostBody(csrf: String): RequestBody {
|
||||
return FormBody.Builder()
|
||||
.add("type", "2")
|
||||
.add("subexport", "Export My List")
|
||||
.add(CSRF, csrf)
|
||||
.build()
|
||||
}
|
||||
|
||||
return writer.toString()
|
||||
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 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")
|
||||
private fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
|
||||
.appendPath("login.php")
|
||||
.toString()
|
||||
|
||||
fun getSearchUrl(query: String) = Uri.parse(baseUrl).buildUpon()
|
||||
.appendEncodedPath("api/manga/search.xml")
|
||||
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()
|
||||
|
||||
fun getListUrl(username: String) = Uri.parse(baseUrl).buildUpon()
|
||||
.appendPath("malappinfo.php")
|
||||
.appendQueryParameter("u", username)
|
||||
.appendQueryParameter("status", "all")
|
||||
.appendQueryParameter("type", "manga")
|
||||
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()
|
||||
|
||||
fun getUpdateUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
|
||||
.appendEncodedPath("api/mangalist/update")
|
||||
.appendPath("${track.media_id}.xml")
|
||||
private fun getAddUrl() = Uri.parse(baseModifyListUrl).buildUpon()
|
||||
.appendPath( "add.json")
|
||||
.toString()
|
||||
|
||||
fun getAddUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
|
||||
.appendEncodedPath("api/mangalist/add")
|
||||
.appendPath("${track.media_id}.xml")
|
||||
.toString()
|
||||
private fun Response.consumeBody(): String? {
|
||||
use {
|
||||
if (it.code() != 200) throw Exception("Login error")
|
||||
return it.body()?.string()
|
||||
}
|
||||
}
|
||||
|
||||
fun createHeaders(username: String, password: String): Headers {
|
||||
return Headers.Builder()
|
||||
.add("Authorization", Credentials.basic(username, password))
|
||||
.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C")
|
||||
.build()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
fun Element.searchTitle() = select("strong").text()!!
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private val ENTRY_TAG = "entry"
|
||||
private val CHAPTER_TAG = "chapter"
|
||||
private val SCORE_TAG = "score"
|
||||
private val STATUS_TAG = "status"
|
||||
|
||||
const val PREFIX_MY = "my:"
|
||||
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,18 +105,14 @@ 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)
|
||||
val dest = updateCover(chapters.last(), this)
|
||||
thumbnail_url = dest?.absolutePath
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initialized = true
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
throw Exception("Chapter not found")
|
||||
}
|
||||
|
||||
return Observable.error(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,43 +1,7 @@
|
||||
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
|
||||
@ -48,43 +12,6 @@ fun HttpSource.getImageUrl(page: Page): Observable<Page> {
|
||||
.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 }
|
||||
}
|
||||
|
||||
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
|
||||
return Observable.from(pages)
|
||||
.filter { !it.imageUrl.isNullOrEmpty() }
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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')
|
||||
* .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,7 +42,6 @@ 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>() {
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
1071
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt
Executable file → Normal file
1071
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,28 +44,12 @@ 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) }
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
@ -255,7 +236,7 @@ class ReaderCustomFilterDialog : DialogFragment() {
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
928
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt
Executable file → Normal file
928
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.
|
||||
*/
|
||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user