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:**
|
**App version:**
|
||||||
|
|
||||||
|
**Android version:**
|
||||||
|
|
||||||
**Issue/Request:**
|
**Issue/Request:**
|
||||||
|
|
||||||
**Steps to reproduce (if applicable)**
|
**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>
|
| Build | Stable | Dev | Contribute | Contact |
|
||||||
<br>
|
|-------|----------|---------|------------|---------|
|
||||||
|
| [](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).
|
# Tachiyomi
|
||||||
### E-Hentai Thread
|
Tachiyomi is a free and open source manga reader for Android.
|
||||||
[https://forums.e-hentai.org/index.php?showtopic=185421](https://forums.e-hentai.org/index.php?showtopic=185421)
|
|
||||||
|
|
||||||
# Download
|

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