Jelajahi Sumber

New reader (#1550)

* Delete old reader

* Add utility methods

* Update dependencies

* Add new reader

* Update tracking services. Extract transition strings into resources

* Restore delete read chapters

* Documentation and some minor changes

* Remove content providers for compressed files, they are not needed anymore

* Update subsampling. New changes allow to parse magic numbers and decode tiles with a single stream. Drop support for custom image decoders. Other minor fixes
inorichi 6 tahun lalu
induk
melakukan
18f89cc341
100 mengubah file dengan 6775 tambahan dan 7129 penghapusan
  1. 5 3
      app/build.gradle
  2. 1 12
      app/src/main/AndroidManifest.xml
  3. 24 4
      app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt
  4. 50 10
      app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt
  5. 180 0
      app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadPendingDeleter.kt
  6. 13 1
      app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt
  7. 12 10
      app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt
  8. 5 9
      app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt
  9. 9 0
      app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt
  10. 74 0
      app/src/main/java/eu/kanade/tachiyomi/data/glide/PassthroughModelLoader.kt
  11. 2 0
      app/src/main/java/eu/kanade/tachiyomi/data/glide/TachiGlideModule.kt
  12. 0 2
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt
  13. 0 2
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt
  14. 65 141
      app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt
  15. 2 5
      app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt
  16. 4 77
      app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt
  17. 11 11
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt
  18. 36 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoadStrategy.kt
  19. 0 140
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoader.kt
  20. 8 3
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageIndicatorTextView.kt
  21. 541 427
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt
  22. 0 13
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderChapter.kt
  23. 47 60
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterSheet.kt
  24. 0 6
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderEvent.kt
  25. 64 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPageSheet.kt
  26. 435 421
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt
  27. 45 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSeekBar.kt
  28. 0 119
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsDialog.kt
  29. 104 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt
  30. 7 6
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt
  31. 84 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt
  32. 43 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt
  33. 48 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt
  34. 54 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt
  35. 222 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt
  36. 46 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt
  37. 89 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt
  38. 60 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt
  39. 33 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ChapterTransition.kt
  40. 58 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt
  41. 15 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt
  42. 21 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ViewerChapters.kt
  43. 46 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/BaseViewer.kt
  44. 74 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/GestureDetectorWithLongTap.kt
  45. 218 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderProgressBar.kt
  46. 0 253
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/BaseReader.kt
  47. 0 40
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/PageDecodeErrorLayout.kt
  48. 0 6
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/OnChapterBoundariesOutListener.kt
  49. 0 276
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PageView.kt
  50. 0 28
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.java
  51. 109 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.kt
  52. 25 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerButton.kt
  53. 107 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt
  54. 464 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt
  55. 0 326
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReader.kt
  56. 0 47
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReaderAdapter.kt
  57. 190 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt
  58. 311 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt
  59. 101 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt
  60. 53 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewers.kt
  61. 0 86
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/horizontal/HorizontalPager.kt
  62. 0 19
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/horizontal/LeftToRightReader.kt
  63. 0 50
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/horizontal/RightToLeftReader.kt
  64. 0 84
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/vertical/VerticalPager.kt
  65. 0 19
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/vertical/VerticalReader.kt
  66. 0 2990
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/vertical/VerticalViewPagerImpl.java
  67. 136 42
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt
  68. 46 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonBaseHolder.kt
  69. 68 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt
  70. 80 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonFrame.kt
  71. 0 316
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt
  72. 55 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt
  73. 504 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt
  74. 0 263
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt
  75. 325 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt
  76. 21 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonSubsamplingImageView.kt
  77. 195 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt
  78. 240 0
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt
  79. 17 11
      app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt
  80. 0 8
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt
  81. 3 2
      app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt
  82. 0 38
      app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt
  83. 117 0
      app/src/main/java/eu/kanade/tachiyomi/util/EpubFile.kt
  84. 78 0
      app/src/main/java/eu/kanade/tachiyomi/util/ImageUtil.kt
  85. 0 73
      app/src/main/java/eu/kanade/tachiyomi/util/RarContentProvider.kt
  86. 5 1
      app/src/main/java/eu/kanade/tachiyomi/util/RxExtensions.kt
  87. 0 69
      app/src/main/java/eu/kanade/tachiyomi/util/ZipContentProvider.kt
  88. 5 1
      app/src/main/java/eu/kanade/tachiyomi/widget/ViewPagerAdapter.kt
  89. 9 0
      app/src/main/res/drawable/ic_image_black_24dp.xml
  90. 50 0
      app/src/main/res/layout-land/reader_color_filter_sheet.xml
  91. 22 18
      app/src/main/res/layout/reader_activity.xml
  92. 205 0
      app/src/main/res/layout/reader_color_filter.xml
  93. 39 0
      app/src/main/res/layout/reader_color_filter_sheet.xml
  94. 0 263
      app/src/main/res/layout/reader_custom_filter_dialog.xml
  95. 0 32
      app/src/main/res/layout/reader_page_decode_error.xml
  96. 93 0
      app/src/main/res/layout/reader_page_sheet.xml
  97. 0 45
      app/src/main/res/layout/reader_pager_item.xml
  98. 0 186
      app/src/main/res/layout/reader_settings_dialog.xml
  99. 247 0
      app/src/main/res/layout/reader_settings_sheet.xml
  100. 0 55
      app/src/main/res/layout/reader_webtoon_item.xml

+ 5 - 3
app/build.gradle

@@ -102,7 +102,7 @@ android {
 dependencies {
 
     // Modified dependencies
-    implementation 'com.github.inorichi:subsampling-scale-image-view:81b9d68'
+    implementation('com.github.inorichi:subsampling-scale-image-view:caad3e4')
     implementation 'com.github.inorichi:junrar-android:634c1f5'
 
     // Android support library
@@ -116,7 +116,7 @@ dependencies {
     implementation "com.android.support:support-annotations:$support_library_version"
     implementation "com.android.support:customtabs:$support_library_version"
 
-    implementation 'com.android.support.constraint:constraint-layout:1.1.0-beta6'
+    implementation 'com.android.support.constraint:constraint-layout:1.1.2'
 
     implementation 'com.android.support:multidex:1.0.2'
 
@@ -201,6 +201,8 @@ dependencies {
     implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4'
     implementation 'com.github.mthli:Slice:v1.2'
     implementation 'me.gujun.android.taggroup:library:1.4@aar'
+    implementation 'com.github.chrisbanes:PhotoView:2.1.3'
+    implementation 'com.github.inorichi:DirectionalViewPager:3acc51a'
 
     // Conductor
     implementation "com.github.inorichi.Conductor:conductor:be8b3c5"
@@ -235,7 +237,7 @@ dependencies {
 }
 
 buildscript {
-    ext.kotlin_version = '1.2.30'
+    ext.kotlin_version = '1.2.60'
     repositories {
         mavenCentral()
     }

+ 1 - 12
app/src/main/AndroidManifest.xml

@@ -32,8 +32,7 @@
             <meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/>
         </activity>
         <activity
-            android:name=".ui.reader.ReaderActivity"
-            android:theme="@style/Theme.Reader" />
+            android:name=".ui.reader.ReaderActivity" />
         <activity
             android:name=".widget.CustomLayoutPickerActivity"
             android:label="@string/app_name"
@@ -66,16 +65,6 @@
                 android:resource="@xml/provider_paths" />
         </provider>
 
-        <provider
-            android:name="eu.kanade.tachiyomi.util.ZipContentProvider"
-            android:authorities="${applicationId}.zip-provider"
-            android:exported="false" />
-
-        <provider
-            android:name="eu.kanade.tachiyomi.util.RarContentProvider"
-            android:authorities="${applicationId}.rar-provider"
-            android:exported="false" />
-
         <receiver
             android:name=".data.notification.NotificationReceiver"
             android:exported="false" />

+ 24 - 4
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt

@@ -23,10 +23,12 @@ import java.util.concurrent.TimeUnit
  * @param sourceManager the source manager.
  * @param preferences the preferences of the app.
  */
-class DownloadCache(private val context: Context,
-                    private val provider: DownloadProvider,
-                    private val sourceManager: SourceManager = Injekt.get(),
-                    private val preferences: PreferencesHelper = Injekt.get()) {
+class DownloadCache(
+        private val context: Context,
+        private val provider: DownloadProvider,
+        private val sourceManager: SourceManager,
+        private val preferences: PreferencesHelper = Injekt.get()
+) {
 
     /**
      * The interval after which this cache should be invalidated. 1 hour shouldn't cause major
@@ -194,6 +196,24 @@ class DownloadCache(private val context: Context,
         }
     }
 
+    /**
+     * Removes a list of chapters that have been deleted from this cache.
+     *
+     * @param chapters the list of chapter to remove.
+     * @param manga the manga of the chapter.
+     */
+    @Synchronized
+    fun removeChapters(chapters: List<Chapter>, manga: Manga) {
+        val sourceDir = rootDir.files[manga.source] ?: return
+        val mangaDir = sourceDir.files[provider.getMangaDirName(manga)] ?: return
+        for (chapter in chapters) {
+            val chapterDirName = provider.getChapterDirName(chapter)
+            if (chapterDirName in mangaDir.files) {
+                mangaDir.files -= chapterDirName
+            }
+        }
+    }
+
     /**
      * Removes a manga that has been deleted from this cache.
      *

+ 50 - 10
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt

@@ -7,8 +7,10 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
 import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.download.model.DownloadQueue
 import eu.kanade.tachiyomi.source.Source
+import eu.kanade.tachiyomi.source.SourceManager
 import eu.kanade.tachiyomi.source.model.Page
 import rx.Observable
+import uy.kohesive.injekt.injectLazy
 
 /**
  * This class is used to manage chapter downloads in the application. It must be instantiated once
@@ -19,6 +21,11 @@ import rx.Observable
  */
 class DownloadManager(context: Context) {
 
+    /**
+     * The sources manager.
+     */
+    private val sourceManager by injectLazy<SourceManager>()
+
     /**
      * Downloads provider, used to retrieve the folders where the chapters are or should be stored.
      */
@@ -27,12 +34,17 @@ class DownloadManager(context: Context) {
     /**
      * Cache of downloaded chapters.
      */
-    private val cache = DownloadCache(context, provider)
+    private val cache = DownloadCache(context, provider, sourceManager)
 
     /**
      * Downloader whose only task is to download chapters.
      */
-    private val downloader = Downloader(context, provider, cache)
+    private val downloader = Downloader(context, provider, cache, sourceManager)
+
+    /**
+     * Queue to delay the deletion of a list of chapters until triggered.
+     */
+    private val pendingDeleter = DownloadPendingDeleter(context)
 
     /**
      * Downloads queue, where the pending chapters are stored.
@@ -146,15 +158,20 @@ class DownloadManager(context: Context) {
     }
 
     /**
-     * Deletes the directory of a downloaded chapter.
+     * Deletes the directories of a list of downloaded chapters.
      *
-     * @param chapter the chapter to delete.
-     * @param manga the manga of the chapter.
-     * @param source the source of the chapter.
-     */
-    fun deleteChapter(chapter: Chapter, manga: Manga, source: Source) {
-        provider.findChapterDir(chapter, manga, source)?.delete()
-        cache.removeChapter(chapter, manga)
+     * @param chapters the list of chapters to delete.
+     * @param manga the manga of the chapters.
+     * @param source the source of the chapters.
+     */
+    fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source) {
+        queue.remove(chapters)
+        val chapterDirs = provider.findChapterDirs(chapters, manga, source)
+        chapterDirs.forEach { it.delete() }
+        cache.removeChapters(chapters, manga)
+        if (cache.getDownloadCount(manga) == 0) { // Delete manga directory if empty
+            chapterDirs.firstOrNull()?.parentFile?.delete()
+        }
     }
 
     /**
@@ -164,7 +181,30 @@ class DownloadManager(context: Context) {
      * @param source the source of the manga.
      */
     fun deleteManga(manga: Manga, source: Source) {
+        queue.remove(manga)
         provider.findMangaDir(manga, source)?.delete()
         cache.removeManga(manga)
     }
+
+    /**
+     * Adds a list of chapters to be deleted later.
+     *
+     * @param chapters the list of chapters to delete.
+     * @param manga the manga of the chapters.
+     */
+    fun enqueueDeleteChapters(chapters: List<Chapter>, manga: Manga) {
+        pendingDeleter.addChapters(chapters, manga)
+    }
+
+    /**
+     * Triggers the execution of the deletion of pending chapters.
+     */
+    fun deletePendingChapters() {
+        val pendingChapters = pendingDeleter.getPendingChapters()
+        for ((manga, chapters) in pendingChapters) {
+            val source = sourceManager.get(manga.source) ?: continue
+            deleteChapters(chapters, manga, source)
+        }
+    }
+
 }

+ 180 - 0
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadPendingDeleter.kt

@@ -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
+        }
+    }
+
+}

+ 13 - 1
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt

@@ -81,6 +81,18 @@ class DownloadProvider(private val context: Context) {
         return mangaDir?.findFile(getChapterDirName(chapter))
     }
 
+    /**
+     * Returns a list of downloaded directories for the chapters that exist.
+     *
+     * @param chapters the chapters to query.
+     * @param manga the manga of the chapter.
+     * @param source the source of the chapter.
+     */
+    fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): List<UniFile> {
+        val mangaDir = findMangaDir(manga, source) ?: return emptyList()
+        return chapters.mapNotNull { mangaDir.findFile(getChapterDirName(it)) }
+    }
+
     /**
      * Returns the download directory name for a source.
      *
@@ -108,4 +120,4 @@ class DownloadProvider(private val context: Context) {
         return DiskUtil.buildValidFilename(chapter.name)
     }
 
-}
+}

+ 12 - 10
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt

@@ -14,7 +14,10 @@ import uy.kohesive.injekt.injectLazy
  *
  * @param context the application context.
  */
-class DownloadStore(context: Context) {
+class DownloadStore(
+        context: Context,
+        private val sourceManager: SourceManager
+) {
 
     /**
      * Preference file where active downloads are stored.
@@ -26,11 +29,6 @@ class DownloadStore(context: Context) {
      */
     private val gson: Gson by injectLazy()
 
-    /**
-     * Source manager.
-     */
-    private val sourceManager: SourceManager by injectLazy()
-
     /**
      * Database helper.
      */
@@ -83,7 +81,7 @@ class DownloadStore(context: Context) {
     fun restore(): List<Download> {
         val objs = preferences.all
                 .mapNotNull { it.value as? String }
-                .map { deserialize(it) }
+                .mapNotNull { deserialize(it) }
                 .sortedBy { it.order }
 
         val downloads = mutableListOf<Download>()
@@ -119,8 +117,12 @@ class DownloadStore(context: Context) {
      *
      * @param string the download as string.
      */
-    private fun deserialize(string: String): DownloadObject {
-        return gson.fromJson(string, DownloadObject::class.java)
+    private fun deserialize(string: String): DownloadObject? {
+        return try {
+            gson.fromJson(string, DownloadObject::class.java)
+        } catch (e: Exception) {
+            null
+        }
     }
 
     /**
@@ -132,4 +134,4 @@ class DownloadStore(context: Context) {
      */
     data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int)
 
-}
+}

+ 5 - 9
app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt

@@ -21,7 +21,6 @@ import rx.android.schedulers.AndroidSchedulers
 import rx.schedulers.Schedulers
 import rx.subscriptions.CompositeSubscription
 import timber.log.Timber
-import uy.kohesive.injekt.injectLazy
 
 /**
  * This class is the one in charge of downloading chapters.
@@ -35,28 +34,25 @@ import uy.kohesive.injekt.injectLazy
  * @param context the application context.
  * @param provider the downloads directory provider.
  * @param cache the downloads cache, used to add the downloads to the cache after their completion.
+ * @param sourceManager the source manager.
  */
 class Downloader(
         private val context: Context,
         private val provider: DownloadProvider,
-        private val cache: DownloadCache
+        private val cache: DownloadCache,
+        private val sourceManager: SourceManager
 ) {
 
     /**
      * Store for persisting downloads across restarts.
      */
-    private val store = DownloadStore(context)
+    private val store = DownloadStore(context, sourceManager)
 
     /**
      * Queue where active downloads are kept.
      */
     val queue = DownloadQueue(store)
 
-    /**
-     * Source manager.
-     */
-    private val sourceManager: SourceManager by injectLazy()
-
     /**
      * Notifier for the downloader state and progress.
      */
@@ -382,7 +378,7 @@ class Downloader(
             // Else guess from the uri.
             ?: context.contentResolver.getType(file.uri)
             // Else read magic numbers.
-            ?: DiskUtil.findImageMime { file.openInputStream() }
+            ?: ImageUtil.findImageType { file.openInputStream() }?.mime
 
         return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
     }

+ 9 - 0
app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt

@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.download.model
 
 import com.jakewharton.rxrelay.PublishRelay
 import eu.kanade.tachiyomi.data.database.models.Chapter
+import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.download.DownloadStore
 import eu.kanade.tachiyomi.source.model.Page
 import rx.Observable
@@ -40,6 +41,14 @@ class DownloadQueue(
         find { it.chapter.id == chapter.id }?.let { remove(it) }
     }
 
+    fun remove(chapters: List<Chapter>) {
+        for (chapter in chapters) { remove(chapter) }
+    }
+
+    fun remove(manga: Manga) {
+        filter { it.manga.id == manga.id }.forEach { remove(it) }
+    }
+
     fun clear() {
         queue.forEach { download ->
             download.setStatusSubject(null)

+ 74 - 0
app/src/main/java/eu/kanade/tachiyomi/data/glide/PassthroughModelLoader.kt

@@ -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() {}
+    }
+
+}

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/data/glide/TachiGlideModule.kt

@@ -37,5 +37,7 @@ class TachiGlideModule : AppGlideModule() {
 
         registry.replace(GlideUrl::class.java, InputStream::class.java, networkFactory)
         registry.append(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory())
+        registry.append(InputStream::class.java, InputStream::class.java, PassthroughModelLoader
+            .Factory())
     }
 }

+ 0 - 2
app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt

@@ -31,8 +31,6 @@ object PreferenceKeys {
 
     const val imageScaleType = "pref_image_scale_type_key"
 
-    const val imageDecoder = "image_decoder"
-
     const val zoomStart = "pref_zoom_start_key"
 
     const val readerTheme = "pref_reader_theme_key"

+ 0 - 2
app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt

@@ -59,8 +59,6 @@ class PreferencesHelper(val context: Context) {
 
     fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
 
-    fun imageDecoder() = rxPrefs.getInteger(Keys.imageDecoder, 0)
-
     fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1)
 
     fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0)

+ 65 - 141
app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt

@@ -1,24 +1,22 @@
 package eu.kanade.tachiyomi.source
 
 import android.content.Context
-import android.net.Uri
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.source.model.*
 import eu.kanade.tachiyomi.util.ChapterRecognition
 import eu.kanade.tachiyomi.util.DiskUtil
-import eu.kanade.tachiyomi.util.RarContentProvider
-import eu.kanade.tachiyomi.util.ZipContentProvider
+import eu.kanade.tachiyomi.util.EpubFile
+import eu.kanade.tachiyomi.util.ImageUtil
 import junrar.Archive
 import junrar.rarfile.FileHeader
 import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
-import org.jsoup.Jsoup
-import org.jsoup.nodes.Document
 import rx.Observable
 import timber.log.Timber
 import java.io.File
 import java.io.FileInputStream
 import java.io.InputStream
-import java.util.*
+import java.util.Comparator
+import java.util.Locale
 import java.util.concurrent.TimeUnit
 import java.util.zip.ZipEntry
 import java.util.zip.ZipFile
@@ -107,15 +105,11 @@ class LocalSource(private val context: Context) : CatalogueSource {
                 if (thumbnail_url == null) {
                     val chapters = fetchChapterList(this).toBlocking().first()
                     if (chapters.isNotEmpty()) {
-                        val uri = fetchPageList(chapters.last()).toBlocking().first().firstOrNull()?.uri
-                        if (uri != null) {
-                            val input = context.contentResolver.openInputStream(uri)
-                            try {
-                                val dest = updateCover(context, this, input)
-                                thumbnail_url = dest?.absolutePath
-                            } catch (e: Exception) {
-                                Timber.e(e)
-                            }
+                        try {
+                            val dest = updateCover(chapters.last(), this)
+                            thumbnail_url = dest?.absolutePath
+                        } catch (e: Exception) {
+                            Timber.e(e)
                         }
                     }
                 }
@@ -135,7 +129,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
         val chapters = getBaseDirectories(context)
                 .mapNotNull { File(it, manga.url).listFiles()?.toList() }
                 .flatten()
-                .filter { it.isDirectory || isSupportedFormat(it.extension) }
+                .filter { it.isDirectory || isSupportedFile(it.extension) }
                 .map { chapterFile ->
                     SChapter.create().apply {
                         url = "${manga.url}/${chapterFile.name}"
@@ -150,7 +144,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
                         ChapterRecognition.parseChapterNumber(this, manga)
                     }
                 }
-                .sortedWith(Comparator<SChapter> { c1, c2 ->
+                .sortedWith(Comparator { c1, c2 ->
                     val c = c2.chapter_number.compareTo(c1.chapter_number)
                     if (c == 0) comparator.compare(c2.name, c1.name) else c
                 })
@@ -159,160 +153,90 @@ class LocalSource(private val context: Context) : CatalogueSource {
     }
 
     override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
+        return Observable.error(Exception("Unused"))
+    }
+
+    private fun isSupportedFile(extension: String): Boolean {
+        return extension.toLowerCase() in setOf("zip", "rar", "cbr", "cbz", "epub")
+    }
+
+    fun getFormat(chapter: SChapter): Format {
         val baseDirs = getBaseDirectories(context)
 
         for (dir in baseDirs) {
             val chapFile = File(dir, chapter.url)
             if (!chapFile.exists()) continue
 
-            return Observable.just(getLoader(chapFile).load())
+            return getFormat(chapFile)
         }
-
-        return Observable.error(Exception("Chapter not found"))
+        throw Exception("Chapter not found")
     }
 
-    private fun isSupportedFormat(extension: String): Boolean {
-        return extension.equals("zip", true) || extension.equals("cbz", true)
-                || extension.equals("rar", true) || extension.equals("cbr", true)
-                || extension.equals("epub", true)
-    }
-
-    private fun getLoader(file: File): Loader {
+    private fun getFormat(file: File): Format {
         val extension = file.extension
         return if (file.isDirectory) {
-            DirectoryLoader(file)
+            Format.Directory(file)
         } else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
-            ZipLoader(file)
-        } else if (extension.equals("epub", true)) {
-            EpubLoader(file)
+            Format.Zip(file)
         } else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
-            RarLoader(file)
+            Format.Rar(file)
+        } else if (extension.equals("epub", true)) {
+            Format.Epub(file)
         } else {
             throw Exception("Invalid chapter format")
         }
     }
 
-    private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true))
-
-    override fun getFilterList() = FilterList(OrderBy())
-
-    interface Loader {
-        fun load(): List<Page>
-    }
-
-    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) }) }
+    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) })
-                    .map { Uri.fromFile(it) }
-                    .mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
-        }
-    }
+                    .find { !it.isDirectory && ImageUtil.isImage(it.name, { FileInputStream(it) }) }
 
-    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 } }
+                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) }) }
 
-    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 } }
+                    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) }) }
 
-    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 } }
+                    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) }
 
-        /**
-         * 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
+                    entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
                 }
             }
-            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") }
-        }
+    private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true))
 
-        /**
-         * 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()
-        }
+    override fun getFilterList() = FilterList(OrderBy())
 
-        /**
-         * 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
-                }
-            }
-        }
+    sealed class Format {
+        data class Directory(val file: File) : Format()
+        data class Zip(val file: File) : Format()
+        data class Rar(val file: File): Format()
+        data class Epub(val file: File) : Format()
     }
-}
+
+}

+ 2 - 5
app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt

@@ -2,21 +2,18 @@ package eu.kanade.tachiyomi.source.model
 
 import android.net.Uri
 import eu.kanade.tachiyomi.network.ProgressListener
-import eu.kanade.tachiyomi.ui.reader.ReaderChapter
 import rx.subjects.Subject
 
-class Page(
+open class Page(
         val index: Int,
         val url: String = "",
         var imageUrl: String? = null,
-        @Transient var uri: Uri? = null
+        @Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
 ) : ProgressListener {
 
     val number: Int
         get() = index + 1
 
-    @Transient lateinit var chapter: ReaderChapter
-
     @Transient @Volatile var status: Int = 0
         set(value) {
             field = value

+ 4 - 77
app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt

@@ -1,88 +1,15 @@
 package eu.kanade.tachiyomi.source.online
 
-import android.net.Uri
-import eu.kanade.tachiyomi.data.cache.ChapterCache
-import eu.kanade.tachiyomi.data.database.models.Chapter
 import eu.kanade.tachiyomi.source.model.Page
 import rx.Observable
-import uy.kohesive.injekt.injectLazy
-
-
-// TODO: this should be handled with a different approach.
-
-/**
- * Chapter cache.
- */
-private val chapterCache: ChapterCache by injectLazy()
-
-/**
- * Returns an observable with the page list for a chapter. It tries to return the page list from
- * the local cache, otherwise fallbacks to network.
- *
- * @param chapter the chapter whose page list has to be fetched.
- */
-fun HttpSource.fetchPageListFromCacheThenNet(chapter: Chapter): Observable<List<Page>> {
-    return chapterCache
-            .getPageListFromCache(chapter)
-            .onErrorResumeNext { fetchPageList(chapter) }
-}
-
-/**
- * Returns an observable of the page with the downloaded image.
- *
- * @param page the page whose source image has to be downloaded.
- */
-fun HttpSource.fetchImageFromCacheThenNet(page: Page): Observable<Page> {
-    return if (page.imageUrl.isNullOrEmpty())
-        getImageUrl(page).flatMap { getCachedImage(it) }
-    else
-        getCachedImage(page)
-}
 
 fun HttpSource.getImageUrl(page: Page): Observable<Page> {
     page.status = Page.LOAD_PAGE
     return fetchImageUrl(page)
-            .doOnError { page.status = Page.ERROR }
-            .onErrorReturn { null }
-            .doOnNext { page.imageUrl = it }
-            .map { page }
-}
-
-/**
- * Returns an observable of the page that gets the image from the chapter or fallbacks to
- * network and copies it to the cache calling [cacheImage].
- *
- * @param page the page.
- */
-fun HttpSource.getCachedImage(page: Page): Observable<Page> {
-    val imageUrl = page.imageUrl ?: return Observable.just(page)
-
-    return Observable.just(page)
-            .flatMap {
-                if (!chapterCache.isImageInCache(imageUrl)) {
-                    cacheImage(page)
-                } else {
-                    Observable.just(page)
-                }
-            }
-            .doOnNext {
-                page.uri = Uri.fromFile(chapterCache.getImageFile(imageUrl))
-                page.status = Page.READY
-            }
-            .doOnError { page.status = Page.ERROR }
-            .onErrorReturn { page }
-}
-
-/**
- * Returns an observable of the page that downloads the image to [ChapterCache].
- *
- * @param page the page.
- */
-private fun HttpSource.cacheImage(page: Page): Observable<Page> {
-    page.status = Page.DOWNLOAD_IMAGE
-    return fetchImage(page)
-            .doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
-            .map { page }
+        .doOnError { page.status = Page.ERROR }
+        .onErrorReturn { null }
+        .doOnNext { page.imageUrl = it }
+        .map { page }
 }
 
 fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {

+ 11 - 11
app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt

@@ -20,7 +20,7 @@ import rx.schedulers.Schedulers
 import timber.log.Timber
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
-import java.util.*
+import java.util.Date
 
 /**
  * Presenter of [ChaptersController].
@@ -271,9 +271,8 @@ class ChaptersPresenter(
      * @param chapters the list of chapters to delete.
      */
     fun deleteChapters(chapters: List<ChapterItem>) {
-        Observable.from(chapters)
-                .doOnNext { deleteChapter(it) }
-                .toList()
+        Observable.just(chapters)
+                .doOnNext { deleteChaptersInternal(chapters) }
                 .doOnNext { if (onlyDownloaded()) refreshChapters() }
                 .subscribeOn(Schedulers.io())
                 .observeOn(AndroidSchedulers.mainThread())
@@ -283,14 +282,15 @@ class ChaptersPresenter(
     }
 
     /**
-     * Deletes a chapter from disk. This method is called in a background thread.
-     * @param chapter the chapter to delete.
+     * Deletes a list of chapters from disk. This method is called in a background thread.
+     * @param chapters the chapters to delete.
      */
-    private fun deleteChapter(chapter: ChapterItem) {
-        downloadManager.queue.remove(chapter)
-        downloadManager.deleteChapter(chapter, manga, source)
-        chapter.status = Download.NOT_DOWNLOADED
-        chapter.download = null
+    private fun deleteChaptersInternal(chapters: List<ChapterItem>) {
+        downloadManager.deleteChapters(chapters, manga, source)
+        chapters.forEach {
+            it.status = Download.NOT_DOWNLOADED
+            it.download = null
+        }
     }
 
     /**

+ 36 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoadStrategy.kt

@@ -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 }
+    }
+}

+ 0 - 140
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoader.kt

@@ -1,140 +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.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 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 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
-
-        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 loadPriorizedPage(page: Page) {
-        queue.offer(PriorityPage(page, 1))
-    }
-
-    fun retryPage(page: Page) {
-        queue.offer(PriorityPage(page, 2))
-    }
-
-
-
-    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)
-        }
-
-    }
-
-}

+ 8 - 3
app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageIndicatorTextView.kt

@@ -12,8 +12,13 @@ import android.text.style.ScaleXSpan
 import android.util.AttributeSet
 import android.widget.TextView
 
-class PageIndicatorTextView(context: Context, attrs: AttributeSet? = null) :
-        AppCompatTextView(context, attrs) {
+/**
+ * Page indicator found at the bottom of the reader
+ */
+class PageIndicatorTextView(
+        context: Context,
+        attrs: AttributeSet? = null
+) : AppCompatTextView(context, attrs) {
 
     private val fillColor = Color.rgb(235, 235, 235)
     private val strokeColor = Color.rgb(45, 45, 45)
@@ -53,4 +58,4 @@ class PageIndicatorTextView(context: Context, attrs: AttributeSet? = null) :
             isAccessible = true
         }!!
     }
-}
+}

+ 541 - 427
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt

@@ -1,599 +1,713 @@
 package eu.kanade.tachiyomi.ui.reader
 
+import android.annotation.SuppressLint
+import android.app.ProgressDialog
 import android.content.Context
 import android.content.Intent
 import android.content.pm.ActivityInfo
 import android.content.res.Configuration
 import android.graphics.Color
 import android.os.Build
-import android.os.Build.VERSION_CODES.KITKAT
 import android.os.Bundle
 import android.view.*
-import android.view.WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS
 import android.view.animation.Animation
 import android.view.animation.AnimationUtils
 import android.widget.SeekBar
-import com.afollestad.materialdialogs.MaterialDialog
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.database.models.Chapter
 import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.data.preference.getOrDefault
-import eu.kanade.tachiyomi.source.model.Page
 import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity
-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 eu.kanade.tachiyomi.ui.reader.viewer.pager.vertical.VerticalReader
-import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonReader
+import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.AddToLibraryFirst
+import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Error
+import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Success
+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 eu.kanade.tachiyomi.ui.reader.viewer.pager.L2RPagerViewer
+import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
+import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer
+import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
 import eu.kanade.tachiyomi.util.*
 import eu.kanade.tachiyomi.widget.SimpleAnimationListener
 import eu.kanade.tachiyomi.widget.SimpleSeekBarListener
 import kotlinx.android.synthetic.main.reader_activity.*
 import me.zhanghai.android.systemuihelper.SystemUiHelper
-import me.zhanghai.android.systemuihelper.SystemUiHelper.*
 import nucleus.factory.RequiresPresenter
+import rx.Observable
 import rx.Subscription
 import rx.android.schedulers.AndroidSchedulers
 import rx.subscriptions.CompositeSubscription
 import timber.log.Timber
 import uy.kohesive.injekt.injectLazy
 import java.io.File
-import java.text.DecimalFormat
 import java.util.concurrent.TimeUnit
 
+/**
+ * Activity containing the reader of Tachiyomi. This activity is mostly a container of the
+ * viewers, to which calls from the presenter or UI events are delegated.
+ */
 @RequiresPresenter(ReaderPresenter::class)
 class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
 
-    companion object {
-        @Suppress("unused")
-        const val LEFT_TO_RIGHT = 1
-        const val RIGHT_TO_LEFT = 2
-        const val VERTICAL = 3
-        const val WEBTOON = 4
-
-        const val WHITE_THEME = 0
-        const val BLACK_THEME = 1
-
-        const val MENU_VISIBLE = "menu_visible"
-
-        fun newIntent(context: Context, manga: Manga, chapter: Chapter): Intent {
-            SharedData.put(ReaderEvent(manga, chapter))
-            return Intent(context, ReaderActivity::class.java)
-        }
-    }
-
-    private var viewer: BaseReader? = null
-
-    val subscriptions by lazy { CompositeSubscription() }
-
-    private var customBrightnessSubscription: Subscription? = null
+    /**
+     * Preferences helper.
+     */
+    private val preferences by injectLazy<PreferencesHelper>()
 
-    private var customFilterColorSubscription: Subscription? = null
+    /**
+     * The maximum bitmap size supported by the device.
+     */
+    val maxBitmapSize by lazy { GLUtil.getMaxTextureSize() }
 
-    var readerTheme: Int = 0
+    /**
+     * Viewer used to display the pages (pager, webtoon, ...).
+     */
+    var viewer: BaseViewer? = null
         private set
 
-    var maxBitmapSize: Int = 0
+    /**
+     * Whether the menu is currently visible.
+     */
+    var menuVisible = false
         private set
 
-    private val decimalFormat = DecimalFormat("#.###")
-
-    private val volumeKeysEnabled by lazy { preferences.readWithVolumeKeys().getOrDefault() }
+    /**
+     * System UI helper to hide status & navigation bar on all different API levels.
+     */
+    private var systemUi: SystemUiHelper? = null
 
-    private val volumeKeysInverted by lazy { preferences.readWithVolumeKeysInverted().getOrDefault() }
+    /**
+     * Configuration at reader level, like background color or forced orientation.
+     */
+    private var config: ReaderConfig? = null
 
-    val preferences by injectLazy<PreferencesHelper>()
+    /**
+     * Progress dialog used when switching chapters from the menu buttons.
+     */
+    @Suppress("DEPRECATION")
+    private var progressDialog: ProgressDialog? = null
 
-    private var systemUi: SystemUiHelper? = null
+    companion object {
+        @Suppress("unused")
+        const val LEFT_TO_RIGHT = 1
+        const val RIGHT_TO_LEFT = 2
+        const val VERTICAL = 3
+        const val WEBTOON = 4
 
-    private var menuVisible = false
+        fun newIntent(context: Context, manga: Manga, chapter: Chapter): Intent {
+            val intent = Intent(context, ReaderActivity::class.java)
+            intent.putExtra("manga", manga)
+            intent.putExtra("chapter", chapter.id)
+            return intent
+        }
+    }
 
+    /**
+     * Called when the activity is created. Initializes the presenter and configuration.
+     */
     override fun onCreate(savedState: Bundle?) {
+        setTheme(when (preferences.readerTheme().getOrDefault()) {
+            0 -> R.style.Theme_Reader_Light
+            else -> R.style.Theme_Reader
+        })
         super.onCreate(savedState)
         setContentView(R.layout.reader_activity)
 
-        if (savedState == null && SharedData.get(ReaderEvent::class.java) == null) {
-            finish()
-            return
-        }
+        if (presenter.needsInit()) {
+            val manga = intent.extras.getSerializable("manga") as? Manga
+            val chapter = intent.extras.getLong("chapter", -1)
 
-        setSupportActionBar(toolbar)
-        supportActionBar?.setDisplayHomeAsUpEnabled(true)
-        toolbar.setNavigationOnClickListener {
-            onBackPressed()
-        }
+            if (manga == null || chapter == -1L) {
+                finish()
+                return
+            }
 
-        initializeSettings()
-        initializeBottomMenu()
+            presenter.init(manga, chapter)
+        }
 
         if (savedState != null) {
-            menuVisible = savedState.getBoolean(MENU_VISIBLE)
+            menuVisible = savedState.getBoolean(::menuVisible.name)
         }
 
-        setMenuVisibility(menuVisible)
+        config = ReaderConfig()
+        initializeMenu()
+    }
 
-        maxBitmapSize = GLUtil.getMaxTextureSize()
+    /**
+     * Called when the activity is destroyed. Cleans up the viewer, configuration and any view.
+     */
+    override fun onDestroy() {
+        super.onDestroy()
+        viewer?.destroy()
+        viewer = null
+        config?.destroy()
+        config = null
+        progressDialog?.dismiss()
+        progressDialog = null
+    }
 
-        left_chapter.setOnClickListener {
-            if (viewer != null) {
-                if (viewer is RightToLeftReader)
-                    requestNextChapter()
-                else
-                    requestPreviousChapter()
-            }
-        }
-        right_chapter.setOnClickListener {
-            if (viewer != null) {
-                if (viewer is RightToLeftReader)
-                    requestPreviousChapter()
-                else
-                    requestNextChapter()
-            }
+    /**
+     * Called when the activity is saving instance state. Current progress is persisted if this
+     * activity isn't changing configurations.
+     */
+    override fun onSaveInstanceState(outState: Bundle) {
+        outState.putBoolean(::menuVisible.name, menuVisible)
+        if (!isChangingConfigurations) {
+            presenter.onSaveInstanceStateNonConfigurationChange()
         }
+        super.onSaveInstanceState(outState)
     }
 
-    override fun onDestroy() {
-        toolbar.setNavigationOnClickListener(null)
-        subscriptions.unsubscribe()
-        viewer = null
-        super.onDestroy()
+    /**
+     * Called when the window focus changes. It sets the menu visibility to the last known state
+     * to apply again System UI (for immersive mode).
+     */
+    override fun onWindowFocusChanged(hasFocus: Boolean) {
+        super.onWindowFocusChanged(hasFocus)
+        if (hasFocus) {
+            setMenuVisibility(menuVisible, animate = false)
+        }
     }
 
+    /**
+     * Called when the options menu of the toolbar is being created. It adds our custom menu.
+     */
     override fun onCreateOptionsMenu(menu: Menu): Boolean {
         menuInflater.inflate(R.menu.reader, menu)
         return true
     }
 
+    /**
+     * Called when an item of the options menu was clicked. Used to handle clicks on our menu
+     * entries.
+     */
     override fun onOptionsItemSelected(item: MenuItem): Boolean {
         when (item.itemId) {
-            R.id.action_settings -> ReaderSettingsDialog().show(supportFragmentManager, "settings")
-            R.id.action_custom_filter -> ReaderCustomFilterDialog().show(supportFragmentManager, "filter")
+            R.id.action_settings -> ReaderSettingsSheet(this).show()
+            R.id.action_custom_filter -> ReaderColorFilterSheet(this).show()
             else -> return super.onOptionsItemSelected(item)
         }
         return true
     }
 
-    override fun onSaveInstanceState(outState: Bundle) {
-        outState.putBoolean(MENU_VISIBLE, menuVisible)
-        super.onSaveInstanceState(outState)
+    /**
+     * Called when the user clicks the back key or the button on the toolbar. The call is
+     * delegated to the presenter.
+     */
+    override fun onBackPressed() {
+        presenter.onBackPressed()
+        super.onBackPressed()
     }
 
-    override fun onWindowFocusChanged(hasFocus: Boolean) {
-        super.onWindowFocusChanged(hasFocus)
-        if (hasFocus) {
-            setMenuVisibility(menuVisible, animate = false)
-        }
+    /**
+     * Dispatches a key event. If the viewer doesn't handle it, call the default implementation.
+     */
+    override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+        val handled = viewer?.handleKeyEvent(event) ?: false
+        return handled || super.dispatchKeyEvent(event)
     }
 
-    override fun onBackPressed() {
-        val chapterToUpdate = presenter.getTrackChapterToUpdate()
-
-        if (chapterToUpdate > 0) {
-            if (preferences.askUpdateTrack()) {
-                MaterialDialog.Builder(this)
-                        .content(getString(R.string.confirm_update_manga_sync, chapterToUpdate))
-                        .positiveText(android.R.string.yes)
-                        .negativeText(android.R.string.no)
-                        .onPositive { _, _ -> presenter.updateTrackLastChapterRead(chapterToUpdate) }
-                        .onAny { _, _ -> super.onBackPressed() }
-                        .show()
-            } else {
-                presenter.updateTrackLastChapterRead(chapterToUpdate)
-                super.onBackPressed()
-            }
-        } else {
-            super.onBackPressed()
-        }
+    /**
+     * Dispatches a generic motion event. If the viewer doesn't handle it, call the default
+     * implementation.
+     */
+    override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
+        val handled = viewer?.handleGenericMotionEvent(event) ?: false
+        return handled || super.dispatchGenericMotionEvent(event)
     }
 
-    override fun dispatchKeyEvent(event: KeyEvent): Boolean {
-        if (!isFinishing) {
-            when (event.keyCode) {
-                KeyEvent.KEYCODE_VOLUME_DOWN -> {
-                    if (volumeKeysEnabled) {
-                        if (event.action == KeyEvent.ACTION_UP) {
-                            if (!volumeKeysInverted) viewer?.moveDown() else viewer?.moveUp()
-                        }
-                        return true
-                    }
-                }
-                KeyEvent.KEYCODE_VOLUME_UP -> {
-                    if (volumeKeysEnabled) {
-                        if (event.action == KeyEvent.ACTION_UP) {
-                            if (!volumeKeysInverted) viewer?.moveUp() else viewer?.moveDown()
-                        }
-                        return true
-                    }
+    /**
+     * Initializes the reader menu. It sets up click listeners and the initial visibility.
+     */
+    private fun initializeMenu() {
+        // Set toolbar
+        setSupportActionBar(toolbar)
+        supportActionBar?.setDisplayHomeAsUpEnabled(true)
+        toolbar.setNavigationOnClickListener {
+            onBackPressed()
+        }
+
+        // Init listeners on bottom menu
+        page_seekbar.setOnSeekBarChangeListener(object : SimpleSeekBarListener() {
+            override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) {
+                if (viewer != null && fromUser) {
+                    moveToPageIndex(value)
                 }
             }
+        })
+        left_chapter.setOnClickListener {
+            if (viewer != null) {
+                if (viewer is R2LPagerViewer)
+                    loadNextChapter()
+                else
+                    loadPreviousChapter()
+            }
         }
-        return super.dispatchKeyEvent(event)
-    }
-
-    override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
-        if (!isFinishing) {
-            when (keyCode) {
-                KeyEvent.KEYCODE_DPAD_RIGHT -> viewer?.moveRight()
-                KeyEvent.KEYCODE_DPAD_LEFT -> viewer?.moveLeft()
-                KeyEvent.KEYCODE_DPAD_DOWN -> viewer?.moveDown()
-                KeyEvent.KEYCODE_DPAD_UP -> viewer?.moveUp()
-                KeyEvent.KEYCODE_PAGE_DOWN -> viewer?.moveDown()
-                KeyEvent.KEYCODE_PAGE_UP -> viewer?.moveUp()
-                KeyEvent.KEYCODE_MENU -> toggleMenu()
-                else -> return super.onKeyUp(keyCode, event)
+        right_chapter.setOnClickListener {
+            if (viewer != null) {
+                if (viewer is R2LPagerViewer)
+                    loadPreviousChapter()
+                else
+                    loadNextChapter()
             }
         }
-        return true
-    }
 
-    fun onChapterError(error: Throwable) {
-        Timber.e(error)
-        finish()
-        toast(error.message)
+        // Set initial visibility
+        setMenuVisibility(menuVisible)
     }
 
-    fun onLongClick(page: Page) {
-        MaterialDialog.Builder(this)
-                .title(getString(R.string.options))
-                .items(R.array.reader_image_options)
-                .itemsIds(R.array.reader_image_options_values)
-                .itemsCallback { _, _, i, _ ->
-                    when (i) {
-                        0 -> setImageAsCover(page)
-                        1 -> shareImage(page)
-                        2 -> presenter.savePage(page)
+    /**
+     * Sets the visibility of the menu according to [visible] and with an optional parameter to
+     * [animate] the views.
+     */
+    private fun setMenuVisibility(visible: Boolean, animate: Boolean = true) {
+        menuVisible = visible
+        if (visible) {
+            systemUi?.show()
+            reader_menu.visibility = View.VISIBLE
+
+            if (animate) {
+                val toolbarAnimation = AnimationUtils.loadAnimation(this, R.anim.enter_from_top)
+                toolbarAnimation.setAnimationListener(object : SimpleAnimationListener() {
+                    override fun onAnimationStart(animation: Animation) {
+                        // Fix status bar being translucent the first time it's opened.
+                        if (Build.VERSION.SDK_INT >= 21) {
+                            window.addFlags(
+                                    WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
+                        }
                     }
-                }.show()
-    }
+                })
+                toolbar.startAnimation(toolbarAnimation)
+
+                val bottomAnimation = AnimationUtils.loadAnimation(this, R.anim.enter_from_bottom)
+                reader_menu_bottom.startAnimation(bottomAnimation)
+            }
+        } else {
+            systemUi?.hide()
+
+            if (animate) {
+                val toolbarAnimation = AnimationUtils.loadAnimation(this, R.anim.exit_to_top)
+                toolbarAnimation.setAnimationListener(object : SimpleAnimationListener() {
+                    override fun onAnimationEnd(animation: Animation) {
+                        reader_menu.visibility = View.GONE
+                    }
+                })
+                toolbar.startAnimation(toolbarAnimation)
 
-    fun onChapterAppendError() {
-        // Ignore
+                val bottomAnimation = AnimationUtils.loadAnimation(this, R.anim.exit_to_bottom)
+                reader_menu_bottom.startAnimation(bottomAnimation)
+            }
+        }
     }
 
     /**
-     * Called from the presenter at startup, allowing to prepare the selected reader.
+     * Called from the presenter when a manga is ready. Used to instantiate the appropriate viewer
+     * and the toolbar title.
      */
-    fun onMangaOpen(manga: Manga) {
-        if (viewer == null) {
-            viewer = getOrCreateViewer(manga)
+    fun setManga(manga: Manga) {
+        val prevViewer = viewer
+        val newViewer = when (presenter.getMangaViewer()) {
+            RIGHT_TO_LEFT -> R2LPagerViewer(this)
+            VERTICAL -> VerticalPagerViewer(this)
+            WEBTOON -> WebtoonViewer(this)
+            else -> L2RPagerViewer(this)
         }
-        if (viewer is RightToLeftReader && page_seekbar.rotation != 180f) {
-            // Invert the seekbar for the right to left reader
-            page_seekbar.rotation = 180f
+
+        // Destroy previous viewer if there was one
+        if (prevViewer != null) {
+            prevViewer.destroy()
+            viewer_container.removeAllViews()
         }
-        supportActionBar?.title = manga.title
-        please_wait.visibility = View.VISIBLE
+        viewer = newViewer
+        viewer_container.addView(newViewer.getView())
+
+        toolbar.title = manga.title
+
+        page_seekbar.isRTL = newViewer is R2LPagerViewer
+
+        please_wait.visible()
         please_wait.startAnimation(AnimationUtils.loadAnimation(this, R.anim.fade_in_long))
     }
 
-    fun onChapterReady(chapter: ReaderChapter) {
-        please_wait.visibility = View.GONE
-        val pages = chapter.pages ?: run { onChapterError(Exception("Null pages")); return }
-        val activePage = pages.getOrElse(chapter.requestedPage) { pages.first() }
-
-        viewer?.onPageListReady(chapter, activePage)
-        setActiveChapter(chapter, activePage.index)
+    /**
+     * Called from the presenter whenever a new [viewerChapters] have been set. It delegates the
+     * method to the current viewer, but also set the subtitle on the toolbar.
+     */
+    fun setChapters(viewerChapters: ViewerChapters) {
+        please_wait.gone()
+        viewer?.setChapters(viewerChapters)
+        toolbar.subtitle = viewerChapters.currChapter.chapter.name
     }
 
-    fun onEnterChapter(chapter: ReaderChapter, currentPage: Int) {
-        val activePage = if (currentPage == -1) chapter.pages!!.lastIndex else currentPage
-        presenter.setActiveChapter(chapter)
-        setActiveChapter(chapter, activePage)
+    /**
+     * Called from the presenter if the initial load couldn't load the pages of the chapter. In
+     * this case the activity is closed and a toast is shown to the user.
+     */
+    fun setInitialChapterError(error: Throwable) {
+        Timber.e(error)
+        finish()
+        toast(error.message)
     }
 
-    fun setActiveChapter(chapter: ReaderChapter, currentPage: Int) {
-        val numPages = chapter.pages!!.size
-        if (page_seekbar.rotation != 180f) {
-            right_page_text.text = "$numPages"
-            left_page_text.text = "${currentPage + 1}"
+    /**
+     * Called from the presenter whenever it's loading the next or previous chapter. It shows or
+     * dismisses a non-cancellable dialog to prevent user interaction according to the value of
+     * [show]. This is only used when the next/previous buttons on the toolbar are clicked; the
+     * other cases are handled with chapter transitions on the viewers and chapter preloading.
+     */
+    @Suppress("DEPRECATION")
+    fun setProgressDialog(show: Boolean) {
+        progressDialog?.dismiss()
+        progressDialog = if (show) {
+            ProgressDialog.show(this, null, getString(R.string.loading), true)
         } else {
-            left_page_text.text = "$numPages"
-            right_page_text.text = "${currentPage + 1}"
+            null
         }
-        page_seekbar.max = numPages - 1
-        page_seekbar.progress = currentPage
-
-        supportActionBar?.subtitle = if (chapter.isRecognizedNumber)
-            getString(R.string.chapter_subtitle, decimalFormat.format(chapter.chapter_number.toDouble()))
-        else
-            chapter.name
     }
 
-    fun onAppendChapter(chapter: ReaderChapter) {
-        viewer?.onPageListAppendReady(chapter)
+    /**
+     * Moves the viewer to the given page [index]. It does nothing if the viewer is null or the
+     * page is not found.
+     */
+    fun moveToPageIndex(index: Int) {
+        val viewer = viewer ?: return
+        val currentChapter = presenter.getCurrentChapter() ?: return
+        val page = currentChapter.pages?.getOrNull(index) ?: return
+        viewer.moveToPage(page)
     }
 
-    fun onAdjacentChapters(previous: Chapter?, next: Chapter?) {
-        val isInverted = viewer is RightToLeftReader
-
-        // Chapters are inverted for the right to left reader
-        val hasRightChapter = (if (isInverted) previous else next) != null
-        val hasLeftChapter = (if (isInverted) next else previous) != null
-
-        right_chapter.isEnabled = hasRightChapter
-        right_chapter.alpha = if (hasRightChapter) 1f else 0.4f
-
-        left_chapter.isEnabled = hasLeftChapter
-        left_chapter.alpha = if (hasLeftChapter) 1f else 0.4f
+    /**
+     * Tells the presenter to load the next chapter and mark it as active. The progress dialog
+     * should be automatically shown.
+     */
+    private fun loadNextChapter() {
+        presenter.loadNextChapter()
     }
 
-    private fun getOrCreateViewer(manga: Manga): BaseReader {
-        val mangaViewer = if (manga.viewer == 0) preferences.defaultViewer() else manga.viewer
-
-        // Try to reuse the viewer using its tag
-        var fragment = supportFragmentManager.findFragmentByTag(manga.viewer.toString()) as? BaseReader
-        if (fragment == null) {
-            // Create a new viewer
-            fragment = when (mangaViewer) {
-                RIGHT_TO_LEFT -> RightToLeftReader()
-                VERTICAL -> VerticalReader()
-                WEBTOON -> WebtoonReader()
-                else -> LeftToRightReader()
-            }
-
-            supportFragmentManager.beginTransaction().replace(R.id.reader, fragment, manga.viewer.toString()).commit()
-        }
-        return fragment
+    /**
+     * Tells the presenter to load the previous chapter and mark it as active. The progress dialog
+     * should be automatically shown.
+     */
+    private fun loadPreviousChapter() {
+        presenter.loadPreviousChapter()
     }
 
-    fun onPageChanged(page: Page) {
-        presenter.onPageChanged(page)
-
-        val pageNumber = page.number
-        val pageCount = page.chapter.pages!!.size
-        page_number.text = "$pageNumber/$pageCount"
-        if (page_seekbar.rotation != 180f) {
-            left_page_text.text = "$pageNumber"
+    /**
+     * Called from the viewer whenever a [page] is marked as active. It updates the values of the
+     * bottom menu and delegates the change to the presenter.
+     */
+    @SuppressLint("SetTextI18n")
+    fun onPageSelected(page: ReaderPage) {
+        presenter.onPageSelected(page)
+        val pages = page.chapter.pages ?: return
+
+        // Set bottom page number
+        page_number.text = "${page.number}/${pages.size}"
+
+        // Set seekbar page number
+        if (viewer !is R2LPagerViewer) {
+            left_page_text.text = "${page.number}"
+            right_page_text.text = "${pages.size}"
         } else {
-            right_page_text.text = "$pageNumber"
+            right_page_text.text = "${page.number}"
+            left_page_text.text = "${pages.size}"
         }
+
+        // Set seekbar progress
+        page_seekbar.max = pages.lastIndex
         page_seekbar.progress = page.index
     }
 
-    fun gotoPageInCurrentChapter(pageIndex: Int) {
-        viewer?.let {
-            val activePage = it.getActivePage()
-            if (activePage != null) {
-                val requestedPage = activePage.chapter.pages!![pageIndex]
-                it.setActivePage(requestedPage)
-            }
-        }
+    /**
+     * Called from the viewer whenever a [page] is long clicked. A bottom sheet with a list of
+     * actions to perform is shown.
+     */
+    fun onPageLongTap(page: ReaderPage) {
+        ReaderPageSheet(this, page).show()
+    }
+
+    /**
+     * Called from the viewer when the next chapter should be preloaded. It should be called when
+     * the viewer is reaching the end of the chapter or the transition page is active.
+     */
+    fun requestPreloadNextChapter() {
+        presenter.preloadNextChapter()
+    }
+
+    /**
+     * Called from the viewer when the previous chapter should be preloaded. It should be called
+     * when the viewer is going backwards and reaching the beginning of the chapter or the
+     * transition page is active.
+     */
+    fun requestPreloadPreviousChapter() {
+        presenter.preloadPreviousChapter()
     }
 
+    /**
+     * Called from the viewer to toggle the visibility of the menu. It's implemented on the
+     * viewer because each one implements its own touch and key events.
+     */
     fun toggleMenu() {
         setMenuVisibility(!menuVisible)
     }
 
-    fun requestNextChapter() {
-        if (!presenter.loadNextChapter()) {
-            toast(R.string.no_next_chapter)
-        }
+    /**
+     * Called from the page sheet. It delegates the call to the presenter to do some IO, which
+     * will call [onShareImageResult] with the path the image was saved on when it's ready.
+     */
+    fun shareImage(page: ReaderPage) {
+        presenter.shareImage(page)
     }
 
-    fun requestPreviousChapter() {
-        if (!presenter.loadPreviousChapter()) {
-            toast(R.string.no_previous_chapter)
+    /**
+     * Called from the presenter when a page is ready to be shared. It shows Android's default
+     * sharing tool.
+     */
+    fun onShareImageResult(file: File) {
+        val stream = file.getUriCompat(this)
+        val intent = Intent(Intent.ACTION_SEND).apply {
+            putExtra(Intent.EXTRA_STREAM, stream)
+            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
+            type = "image/*"
         }
+        startActivity(Intent.createChooser(intent, getString(R.string.action_share)))
     }
 
-    private fun initializeBottomMenu() {
-        // Intercept all events in this layout
-        reader_menu_bottom.setOnTouchListener { _, _ -> true }
+    /**
+     * Called from the page sheet. It delegates saving the image of the given [page] on external
+     * storage to the presenter.
+     */
+    fun saveImage(page: ReaderPage) {
+        presenter.saveImage(page)
+    }
 
-        page_seekbar.setOnSeekBarChangeListener(object : SimpleSeekBarListener() {
-            override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) {
-                if (fromUser) {
-                    gotoPageInCurrentChapter(value)
-                }
+    /**
+     * Called from the presenter when a page is saved or fails. It shows a message or logs the
+     * event depending on the [result].
+     */
+    fun onSaveImageResult(result: ReaderPresenter.SaveImageResult) {
+        when (result) {
+            is ReaderPresenter.SaveImageResult.Success -> {
+                toast(R.string.picture_saved)
             }
-        })
+            is ReaderPresenter.SaveImageResult.Error -> {
+                Timber.e(result.error)
+            }
+        }
     }
 
-    private fun initializeSettings() {
-        subscriptions += preferences.rotation().asObservable()
-                .subscribe { setRotation(it) }
+    /**
+     * Called from the page sheet. It delegates setting the image of the given [page] as the
+     * cover to the presenter.
+     */
+    fun setAsCover(page: ReaderPage) {
+        presenter.setAsCover(page)
+    }
 
-        subscriptions += preferences.showPageNumber().asObservable()
+    /**
+     * Called from the presenter when a page is set as cover or fails. It shows a different message
+     * depending on the [result].
+     */
+    fun onSetAsCoverResult(result: ReaderPresenter.SetAsCoverResult) {
+        toast(when (result) {
+            Success -> R.string.cover_updated
+            AddToLibraryFirst -> R.string.notification_first_add_to_library
+            Error -> R.string.notification_cover_update_failed
+        })
+    }
+
+    /**
+     * Class that handles the user preferences of the reader.
+     */
+    private inner class ReaderConfig {
+
+        /**
+         * List of subscriptions to keep while the reader is alive.
+         */
+        private val subscriptions = CompositeSubscription()
+
+        /**
+         * Custom brightness subscription.
+         */
+        private var customBrightnessSubscription: Subscription? = null
+
+        /**
+         * Custom color filter subscription.
+         */
+        private var customFilterColorSubscription: Subscription? = null
+
+        /**
+         * Initializes the reader subscriptions.
+         */
+        init {
+            val sharedRotation = preferences.rotation().asObservable().share()
+            val initialRotation = sharedRotation.take(1)
+            val rotationUpdates = sharedRotation.skip(1)
+                .delay(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
+
+            subscriptions += Observable.merge(initialRotation, rotationUpdates)
+                .subscribe { setOrientation(it) }
+
+            subscriptions += preferences.readerTheme().asObservable()
+                .skip(1) // We only care about updates
+                .subscribe { recreate() }
+
+            subscriptions += preferences.showPageNumber().asObservable()
                 .subscribe { setPageNumberVisibility(it) }
 
-        subscriptions += preferences.fullscreen().asObservable()
+            subscriptions += preferences.fullscreen().asObservable()
                 .subscribe { setFullscreen(it) }
 
-        subscriptions += preferences.keepScreenOn().asObservable()
+            subscriptions += preferences.keepScreenOn().asObservable()
                 .subscribe { setKeepScreenOn(it) }
 
-        subscriptions += preferences.customBrightness().asObservable()
+            subscriptions += preferences.customBrightness().asObservable()
                 .subscribe { setCustomBrightness(it) }
 
-        subscriptions += preferences.colorFilter().asObservable()
+            subscriptions += preferences.colorFilter().asObservable()
                 .subscribe { setColorFilter(it) }
+        }
 
-        subscriptions += preferences.readerTheme().asObservable()
-                .distinctUntilChanged()
-                .subscribe { applyTheme(it) }
-    }
+        /**
+         * Called when the reader is being destroyed. It cleans up all the subscriptions.
+         */
+        fun destroy() {
+            subscriptions.unsubscribe()
+            customBrightnessSubscription = null
+            customFilterColorSubscription = null
+        }
+
+        /**
+         * Forces the user preferred [orientation] on the activity.
+         */
+        private fun setOrientation(orientation: Int) {
+            val newOrientation = when (orientation) {
+                // Lock in current orientation
+                2 -> {
+                    val currentOrientation = resources.configuration.orientation
+                    if (currentOrientation == Configuration.ORIENTATION_PORTRAIT) {
+                        ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
+                    } else {
+                        ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+                    }
+                }
+                // Lock in portrait
+                3 -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
+                // Lock in landscape
+                4 -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+                // Rotation free
+                else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+            }
 
-    private fun setRotation(rotation: Int) {
-        when (rotation) {
-            // Rotation free
-            1 -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
-            // Lock in current rotation
-            2 -> {
-                val currentOrientation = resources.configuration.orientation
-                setRotation(if (currentOrientation == Configuration.ORIENTATION_PORTRAIT) 3 else 4)
+            if (newOrientation != requestedOrientation) {
+                requestedOrientation = newOrientation
             }
-            // Lock in portrait
-            3 -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
-            // Lock in landscape
-            4 -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
         }
-    }
 
-    private fun setPageNumberVisibility(visible: Boolean) {
-        page_number.visibility = if (visible) View.VISIBLE else View.INVISIBLE
-    }
+        /**
+         * Sets the visibility of the bottom page indicator according to [visible].
+         */
+        private fun setPageNumberVisibility(visible: Boolean) {
+            page_number.visibility = if (visible) View.VISIBLE else View.INVISIBLE
+        }
 
-    private fun setFullscreen(enabled: Boolean) {
-        systemUi = if (enabled) {
-            val level = if (Build.VERSION.SDK_INT >= KITKAT) LEVEL_IMMERSIVE else LEVEL_HIDE_STATUS_BAR
-            val flags = FLAG_IMMERSIVE_STICKY or FLAG_LAYOUT_IN_SCREEN_OLDER_DEVICES
-            SystemUiHelper(this, level, flags)
-        } else {
-            null
+        /**
+         * Sets the fullscreen reading mode (immersive) according to [enabled].
+         */
+        private fun setFullscreen(enabled: Boolean) {
+            systemUi = if (enabled) {
+                val level = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+                    SystemUiHelper.LEVEL_IMMERSIVE
+                } else {
+                    SystemUiHelper.LEVEL_HIDE_STATUS_BAR
+                }
+                val flags = SystemUiHelper.FLAG_IMMERSIVE_STICKY or
+                        SystemUiHelper.FLAG_LAYOUT_IN_SCREEN_OLDER_DEVICES
+
+                SystemUiHelper(this@ReaderActivity, level, flags)
+            } else {
+                null
+            }
         }
-    }
 
-    private fun setKeepScreenOn(enabled: Boolean) {
-        if (enabled) {
-            window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
-        } else {
-            window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+        /**
+         * Sets the keep screen on mode according to [enabled].
+         */
+        private fun setKeepScreenOn(enabled: Boolean) {
+            if (enabled) {
+                window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+            } else {
+                window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+            }
         }
-    }
 
-    private fun setCustomBrightness(enabled: Boolean) {
-        if (enabled) {
-            customBrightnessSubscription = preferences.customBrightnessValue().asObservable()
+        /**
+         * Sets the custom brightness overlay according to [enabled].
+         */
+        private fun setCustomBrightness(enabled: Boolean) {
+            if (enabled) {
+                customBrightnessSubscription = preferences.customBrightnessValue().asObservable()
                     .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
                     .subscribe { setCustomBrightnessValue(it) }
 
-            subscriptions.add(customBrightnessSubscription)
-        } else {
-            customBrightnessSubscription?.let { subscriptions.remove(it) }
-            setCustomBrightnessValue(0)
+                subscriptions.add(customBrightnessSubscription)
+            } else {
+                customBrightnessSubscription?.let { subscriptions.remove(it) }
+                setCustomBrightnessValue(0)
+            }
         }
-    }
 
-    private fun setColorFilter(enabled: Boolean) {
-        if (enabled) {
-            customFilterColorSubscription = preferences.colorFilterValue().asObservable()
+        /**
+         * Sets the color filter overlay according to [enabled].
+         */
+        private fun setColorFilter(enabled: Boolean) {
+            if (enabled) {
+                customFilterColorSubscription = preferences.colorFilterValue().asObservable()
                     .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
                     .subscribe { setColorFilterValue(it) }
 
-            subscriptions.add(customFilterColorSubscription)
-        } else {
-            customFilterColorSubscription?.let { subscriptions.remove(it) }
-            color_overlay.visibility = View.GONE
-        }
-    }
-
-    /**
-     * Sets the brightness of the screen. Range is [-75, 100].
-     * From -75 to -1 a semi-transparent black view is shown at the top with the minimum brightness.
-     * From 1 to 100 it sets that value as brightness.
-     * 0 sets system brightness and hides the overlay.
-     */
-    private fun setCustomBrightnessValue(value: Int) {
-        // Calculate and set reader brightness.
-        val readerBrightness = if (value > 0) {
-            value / 100f
-        } else if (value < 0) {
-            0.01f
-        } else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
-
-        window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
-
-        // Set black overlay visibility.
-        if (value < 0) {
-            brightness_overlay.visibility = View.VISIBLE
-            val alpha = (Math.abs(value) * 2.56).toInt()
-            brightness_overlay.setBackgroundColor(Color.argb(alpha, 0, 0, 0))
-        } else {
-            brightness_overlay.visibility = View.GONE
-        }
-    }
-
-    private fun setColorFilterValue(value: Int) {
-        color_overlay.visibility = View.VISIBLE
-        color_overlay.setBackgroundColor(value)
-    }
-
-    private fun applyTheme(theme: Int) {
-        readerTheme = theme
-        val rootView = window.decorView.rootView
-        if (theme == BLACK_THEME) {
-            rootView.setBackgroundColor(Color.BLACK)
-        } else {
-            rootView.setBackgroundColor(Color.WHITE)
-        }
-    }
-
-    private fun setMenuVisibility(visible: Boolean, animate: Boolean = true) {
-        menuVisible = visible
-        if (visible) {
-            systemUi?.show()
-            reader_menu.visibility = View.VISIBLE
-
-            if (animate) {
-                val toolbarAnimation = AnimationUtils.loadAnimation(this, R.anim.enter_from_top)
-                toolbarAnimation.setAnimationListener(object : SimpleAnimationListener() {
-                    override fun onAnimationStart(animation: Animation) {
-                        // Fix status bar being translucent the first time it's opened.
-                        if (Build.VERSION.SDK_INT >= 21) {
-                            window.addFlags(FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
-                        }
-                    }
-                })
-                toolbar.startAnimation(toolbarAnimation)
-
-                val bottomMenuAnimation = AnimationUtils.loadAnimation(this, R.anim.enter_from_bottom)
-                reader_menu_bottom.startAnimation(bottomMenuAnimation)
+                subscriptions.add(customFilterColorSubscription)
+            } else {
+                customFilterColorSubscription?.let { subscriptions.remove(it) }
+                color_overlay.visibility = View.GONE
             }
-        } else {
-            systemUi?.hide()
-
-            if (animate) {
-                val toolbarAnimation = AnimationUtils.loadAnimation(this, R.anim.exit_to_top)
-                toolbarAnimation.setAnimationListener(object : SimpleAnimationListener() {
-                    override fun onAnimationEnd(animation: Animation) {
-                        reader_menu.visibility = View.GONE
-                    }
-                })
-                toolbar.startAnimation(toolbarAnimation)
+        }
 
-                val bottomMenuAnimation = AnimationUtils.loadAnimation(this, R.anim.exit_to_bottom)
-                reader_menu_bottom.startAnimation(bottomMenuAnimation)
+        /**
+         * Sets the brightness of the screen. Range is [-75, 100].
+         * From -75 to -1 a semi-transparent black view is overlaid with the minimum brightness.
+         * From 1 to 100 it sets that value as brightness.
+         * 0 sets system brightness and hides the overlay.
+         */
+        private fun setCustomBrightnessValue(value: Int) {
+            // Calculate and set reader brightness.
+            val readerBrightness = if (value > 0) {
+                value / 100f
+            } else if (value < 0) {
+                0.01f
+            } else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
+
+            window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
+
+            // Set black overlay visibility.
+            if (value < 0) {
+                brightness_overlay.visibility = View.VISIBLE
+                val alpha = (Math.abs(value) * 2.56).toInt()
+                brightness_overlay.setBackgroundColor(Color.argb(alpha, 0, 0, 0))
+            } else {
+                brightness_overlay.visibility = View.GONE
             }
         }
-    }
-
-    /**
-     * Start a share intent that lets user share image
-     *
-     * @param page page object containing image information.
-     */
-    private fun shareImage(page: Page) {
-        if (page.status != Page.READY)
-            return
 
-        var uri = page.uri ?: return
-        if (uri.toString().startsWith("file://")) {
-            uri = File(uri.toString().substringAfter("file://")).getUriCompat(this)
+        /**
+         * Sets the color filter [value].
+         */
+        private fun setColorFilterValue(value: Int) {
+            color_overlay.visibility = View.VISIBLE
+            color_overlay.setBackgroundColor(value)
         }
-        val intent = Intent(Intent.ACTION_SEND).apply {
-            putExtra(Intent.EXTRA_STREAM, uri)
-            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
-            type = "image/*"
-        }
-        startActivity(Intent.createChooser(intent, getString(R.string.action_share)))
-    }
-
-    /**
-     * Sets the given page as the cover of the manga.
-     *
-     * @param page the page containing the image to set as cover.
-     */
-    private fun setImageAsCover(page: Page) {
-        if (page.status != Page.READY)
-            return
-
-        MaterialDialog.Builder(this)
-                .content(getString(R.string.confirm_set_image_as_cover))
-                .positiveText(android.R.string.yes)
-                .negativeText(android.R.string.no)
-                .onPositive { _, _ -> presenter.setImageAsCover(page) }
-                .show()
 
     }
 

+ 0 - 13
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderChapter.kt

@@ -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
-}

+ 47 - 60
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderCustomFilterDialog.kt → app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterSheet.kt

@@ -1,19 +1,19 @@
 package eu.kanade.tachiyomi.ui.reader
 
-import android.app.Dialog
 import android.graphics.Color
-import android.os.Bundle
 import android.support.annotation.ColorInt
-import android.support.v4.app.DialogFragment
+import android.support.design.widget.BottomSheetBehavior
+import android.support.design.widget.BottomSheetDialog
 import android.view.View
+import android.view.ViewGroup
 import android.widget.SeekBar
-import com.afollestad.materialdialogs.MaterialDialog
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.data.preference.getOrDefault
 import eu.kanade.tachiyomi.util.plusAssign
 import eu.kanade.tachiyomi.widget.SimpleSeekBarListener
-import kotlinx.android.synthetic.main.reader_custom_filter_dialog.view.*
+import kotlinx.android.synthetic.main.reader_color_filter.*
+import kotlinx.android.synthetic.main.reader_color_filter_sheet.*
 import rx.Subscription
 import rx.android.schedulers.AndroidSchedulers
 import rx.subscriptions.CompositeSubscription
@@ -21,33 +21,18 @@ import uy.kohesive.injekt.injectLazy
 import java.util.concurrent.TimeUnit
 
 /**
- * Custom dialog which can be used to set overlay value's
+ * Color filter sheet to toggle custom filter and brightness overlay.
  */
-class ReaderCustomFilterDialog : DialogFragment() {
+class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activity) {
 
-    companion object {
-        /** Integer mask of alpha value **/
-        private const val ALPHA_MASK: Long = 0xFF000000
-
-        /** Integer mask of red value **/
-        private const val RED_MASK: Long = 0x00FF0000
-
-        /** Integer mask of green value **/
-        private const val GREEN_MASK: Long = 0x0000FF00
-
-        /** Integer mask of blue value **/
-        private const val BLUE_MASK: Long = 0x000000FF
-    }
-
-    /**
-     * Provides operations to manage preferences
-     */
     private val preferences by injectLazy<PreferencesHelper>()
 
+    private var behavior: BottomSheetBehavior<*>? = null
+
     /**
-     * Subscription used for filter overlay
+     * Subscriptions used for this dialog
      */
-    private lateinit var subscriptions: CompositeSubscription
+    private val subscriptions = CompositeSubscription()
 
     /**
      * Subscription used for custom brightness overlay
@@ -59,34 +44,18 @@ class ReaderCustomFilterDialog : DialogFragment() {
      */
     private var customFilterColorSubscription: Subscription? = null
 
-    /**
-     * This method will be called after onCreate(Bundle)
-     * @param savedState The last saved instance state of the Fragment.
-     */
-    override fun onCreateDialog(savedState: Bundle?): Dialog {
-        val dialog = MaterialDialog.Builder(activity!!)
-                .customView(R.layout.reader_custom_filter_dialog, false)
-                .positiveText(android.R.string.ok)
-                .build()
+    init {
+        val view = activity.layoutInflater.inflate(R.layout.reader_color_filter_sheet, null)
+        setContentView(view)
 
-        subscriptions = CompositeSubscription()
-        onViewCreated(dialog.view, savedState)
-
-        return dialog
-    }
+        behavior = BottomSheetBehavior.from(view.parent as ViewGroup)
 
-    /**
-     * Called immediately after onCreateView()
-     * @param view The View returned by onCreateDialog.
-     * @param savedInstanceState If non-null, this fragment is being re-constructed
-     */
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(view) {
         // Initialize subscriptions.
         subscriptions += preferences.colorFilter().asObservable()
-                .subscribe { setColorFilter(it, view) }
+            .subscribe { setColorFilter(it, view) }
 
         subscriptions += preferences.customBrightness().asObservable()
-                .subscribe { setCustomBrightness(it, view) }
+            .subscribe { setCustomBrightness(it, view) }
 
         // Get color and update values
         val color = preferences.colorFilterValue().getOrDefault()
@@ -154,7 +123,19 @@ class ReaderCustomFilterDialog : DialogFragment() {
                 }
             }
         })
+    }
+
+    override fun onStart() {
+        super.onStart()
+        behavior?.skipCollapsed = true
+        behavior?.state = BottomSheetBehavior.STATE_EXPANDED
+    }
 
+    override fun onDetachedFromWindow() {
+        super.onDetachedFromWindow()
+        subscriptions.unsubscribe()
+        customBrightnessSubscription = null
+        customFilterColorSubscription = null
     }
 
     /**
@@ -210,8 +191,8 @@ class ReaderCustomFilterDialog : DialogFragment() {
     private fun setCustomBrightness(enabled: Boolean, view: View) {
         if (enabled) {
             customBrightnessSubscription = preferences.customBrightnessValue().asObservable()
-                    .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
-                    .subscribe { setCustomBrightnessValue(it, view) }
+                .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
+                .subscribe { setCustomBrightnessValue(it, view) }
 
             subscriptions.add(customBrightnessSubscription)
         } else {
@@ -249,13 +230,13 @@ class ReaderCustomFilterDialog : DialogFragment() {
     private fun setColorFilter(enabled: Boolean, view: View) {
         if (enabled) {
             customFilterColorSubscription = preferences.colorFilterValue().asObservable()
-                    .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
-                    .subscribe { setColorFilterValue(it, view) }
+                .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
+                .subscribe { setColorFilterValue(it, view) }
 
             subscriptions.add(customFilterColorSubscription)
         } else {
             customFilterColorSubscription?.let { subscriptions.remove(it) }
-            view.color_overlay.visibility = View.GONE
+            color_overlay.visibility = View.GONE
         }
         setColorFilterSeekBar(enabled, view)
     }
@@ -319,12 +300,18 @@ class ReaderCustomFilterDialog : DialogFragment() {
         return color and 0xFF
     }
 
-    /**
-     * Called when dialog is dismissed
-     */
-    override fun onDestroyView() {
-        subscriptions.unsubscribe()
-        super.onDestroyView()
+    private companion object {
+        /** Integer mask of alpha value **/
+        const val ALPHA_MASK: Long = 0xFF000000
+
+        /** Integer mask of red value **/
+        const val RED_MASK: Long = 0x00FF0000
+
+        /** Integer mask of green value **/
+        const val GREEN_MASK: Long = 0x0000FF00
+
+        /** Integer mask of blue value **/
+        const val BLUE_MASK: Long = 0x000000FF
     }
 
-}
+}

+ 0 - 6
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderEvent.kt

@@ -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)

+ 64 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPageSheet.kt

@@ -0,0 +1,64 @@
+package eu.kanade.tachiyomi.ui.reader
+
+import android.support.design.widget.BottomSheetDialog
+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() }
+    }
+
+    /**
+     * 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()
+    }
+
+}

+ 435 - 421
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt

@@ -1,28 +1,29 @@
 package eu.kanade.tachiyomi.ui.reader
 
+import android.app.Application
 import android.os.Bundle
 import android.os.Environment
-import android.webkit.MimeTypeMap
+import com.jakewharton.rxrelay.BehaviorRelay
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.cache.ChapterCache
 import eu.kanade.tachiyomi.data.cache.CoverCache
 import eu.kanade.tachiyomi.data.database.DatabaseHelper
-import eu.kanade.tachiyomi.data.database.models.Chapter
 import eu.kanade.tachiyomi.data.database.models.History
 import eu.kanade.tachiyomi.data.database.models.Manga
-import eu.kanade.tachiyomi.data.database.models.Track
 import eu.kanade.tachiyomi.data.download.DownloadManager
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.data.track.TrackManager
 import eu.kanade.tachiyomi.source.LocalSource
 import eu.kanade.tachiyomi.source.SourceManager
 import eu.kanade.tachiyomi.source.model.Page
-import eu.kanade.tachiyomi.source.online.HttpSource
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
+import eu.kanade.tachiyomi.ui.reader.loader.ChapterLoader
+import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader
+import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
+import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
+import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
 import eu.kanade.tachiyomi.util.DiskUtil
-import eu.kanade.tachiyomi.util.RetryWithDelay
-import eu.kanade.tachiyomi.util.SharedData
-import eu.kanade.tachiyomi.util.toast
+import eu.kanade.tachiyomi.util.ImageUtil
+import rx.Completable
 import rx.Observable
 import rx.Subscription
 import rx.android.schedulers.AndroidSchedulers
@@ -31,572 +32,585 @@ import timber.log.Timber
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 import java.io.File
-import java.net.URLConnection
-import java.util.Comparator
 import java.util.Date
+import java.util.concurrent.TimeUnit
 
 /**
- * Presenter of [ReaderActivity].
+ * Presenter used by the activity to perform background operations.
  */
 class ReaderPresenter(
-        val prefs: PreferencesHelper = Injekt.get(),
-        val db: DatabaseHelper = Injekt.get(),
-        val downloadManager: DownloadManager = Injekt.get(),
-        val trackManager: TrackManager = Injekt.get(),
-        val sourceManager: SourceManager = Injekt.get(),
-        val chapterCache: ChapterCache = Injekt.get(),
-        val coverCache: CoverCache = Injekt.get()
+        private val db: DatabaseHelper = Injekt.get(),
+        private val sourceManager: SourceManager = Injekt.get(),
+        private val downloadManager: DownloadManager = Injekt.get(),
+        private val coverCache: CoverCache = Injekt.get(),
+        val preferences: PreferencesHelper = Injekt.get()
 ) : BasePresenter<ReaderActivity>() {
 
-    private val context = prefs.context
-
     /**
-     * Manga being read.
+     * The manga loaded in the reader. It can be null when instantiated for a short time.
      */
-    lateinit var manga: Manga
+    var manga: Manga? = null
         private set
 
     /**
-     * Active chapter.
+     * The chapter id of the currently loaded chapter. Used to restore from process kill.
      */
-    lateinit var chapter: ReaderChapter
-        private set
+    private var chapterId = -1L
+
+    /**
+     * The chapter loader for the loaded manga. It'll be null until [manga] is set.
+     */
+    private var loader: ChapterLoader? = null
 
     /**
-     * Previous chapter of the active.
+     * Subscription to prevent setting chapters as active from multiple threads.
      */
-    private var prevChapter: ReaderChapter? = null
+    private var activeChapterSubscription: Subscription? = null
 
     /**
-     * Next chapter of the active.
+     * Relay for currently active viewer chapters.
      */
-    private var nextChapter: ReaderChapter? = null
+    private val viewerChaptersRelay = BehaviorRelay.create<ViewerChapters>()
 
     /**
-     * Source of the manga.
+     * Relay used when loading prev/next chapter needed to lock the UI (with a dialog).
      */
-    private val source by lazy { sourceManager.getOrStub(manga.source) }
+    private val isLoadingAdjacentChapterRelay = BehaviorRelay.create<Boolean>()
 
     /**
      * Chapter list for the active manga. It's retrieved lazily and should be accessed for the first
      * time in a background thread to avoid blocking the UI.
      */
     private val chapterList by lazy {
-        val dbChapters = db.getChapters(manga).executeAsBlocking().map { it.toModel() }
-
-        val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) {
-            Manga.SORTING_SOURCE -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) }
-            Manga.SORTING_NUMBER -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) }
-            else -> throw NotImplementedError("Unknown sorting method")
-        }
-
-        dbChapters.sortedWith(Comparator<Chapter> { c1, c2 -> sortFunction(c1, c2) })
+        val manga = manga!!
+        val dbChapters = db.getChapters(manga).executeAsBlocking()
+        val selectedChapter = dbChapters.find { it.id == chapterId }
+            ?: error("Requested chapter of id $chapterId not found in chapter list")
+
+        when (manga.sorting) {
+            Manga.SORTING_SOURCE -> ChapterLoadBySource().get(dbChapters)
+            Manga.SORTING_NUMBER -> ChapterLoadByNumber().get(dbChapters, selectedChapter)
+            else -> error("Unknown sorting method")
+        }.map(::ReaderChapter)
     }
 
     /**
-     * Map of chapters that have been loaded in the reader.
+     * Called when the presenter is created. It retrieves the saved active chapter if the process
+     * was restored.
      */
-    private val loadedChapters = hashMapOf<Long?, ReaderChapter>()
+    override fun onCreate(savedState: Bundle?) {
+        super.onCreate(savedState)
+        if (savedState != null) {
+            chapterId = savedState.getLong(::chapterId.name, -1)
+        }
+    }
 
     /**
-     * List of manga services linked to the active manga, or null if auto syncing is not enabled.
+     * Called when the presenter is destroyed. It saves the current progress and cleans up
+     * references on the currently active chapters.
      */
-    private var trackList: List<Track>? = null
+    override fun onDestroy() {
+        super.onDestroy()
+        val currentChapters = viewerChaptersRelay.value
+        if (currentChapters != null) {
+            currentChapters.unref()
+            saveChapterProgress(currentChapters.currChapter)
+            saveChapterHistory(currentChapters.currChapter)
+        }
+    }
 
     /**
-     * Chapter loader whose job is to obtain the chapter list and initialize every page.
+     * Called when the presenter instance is being saved. It saves the currently active chapter
+     * id and the last page read.
      */
-    private val loader by lazy { ChapterLoader(downloadManager, manga, source) }
+    override fun onSave(state: Bundle) {
+        super.onSave(state)
+        val currentChapter = getCurrentChapter()
+        if (currentChapter != null) {
+            currentChapter.requestedPage = currentChapter.chapter.last_page_read
+            state.putLong(::chapterId.name, currentChapter.chapter.id!!)
+        }
+    }
 
     /**
-     * Subscription for appending a chapter to the reader (seamless mode).
+     * Called when the user pressed the back button and is going to leave the reader. Used to
+     * update tracking services and trigger deletion of the downloaded chapters.
      */
-    private var appenderSubscription: Subscription? = null
+    fun onBackPressed() {
+        updateTrackLastChapterRead()
+        deletePendingChapters()
+    }
 
     /**
-     * Subscription for retrieving the adjacent chapters to the current one.
+     * Called when the activity is saved and not changing configurations. It updates the database
+     * to persist the current progress of the active chapter.
      */
-    private var adjacentChaptersSubscription: Subscription? = null
+    fun onSaveInstanceStateNonConfigurationChange() {
+        val currentChapter = getCurrentChapter() ?: return
+        saveChapterProgress(currentChapter)
+    }
 
     /**
-     * Whether the active chapter has been loaded.
+     * Whether this presenter is initialized yet.
      */
-    private var chapterLoaded = false
-
-    companion object {
-        /**
-         * Id of the restartable that loads the active chapter.
-         */
-        private const val LOAD_ACTIVE_CHAPTER = 1
+    fun needsInit(): Boolean {
+        return manga == null
     }
 
-    override fun onCreate(savedState: Bundle?) {
-        super.onCreate(savedState)
-
-        if (savedState == null) {
-            val event = SharedData.get(ReaderEvent::class.java) ?: return
-            manga = event.manga
-            chapter = event.chapter.toModel()
-        } else {
-            manga = savedState.getSerializable(ReaderPresenter::manga.name) as Manga
-            chapter = savedState.getSerializable(ReaderPresenter::chapter.name) as ReaderChapter
-        }
-
-        // Send the active manga to the view to initialize the reader.
-        Observable.just(manga)
-                .subscribeLatestCache({ view, manga -> view.onMangaOpen(manga) })
+    /**
+     * Initializes this presenter with the given [manga] and [initialChapterId]. This method will
+     * set the chapter loader, view subscriptions and trigger an initial load.
+     */
+    fun init(manga: Manga, initialChapterId: Long) {
+        if (!needsInit()) return
 
-        // Retrieve the sync list if auto syncing is enabled.
-        if (prefs.autoUpdateTrack()) {
-            add(db.getTracks(manga).asRxSingle()
-                    .subscribe({ trackList = it }))
-        }
+        this.manga = manga
+        if (chapterId == -1L) chapterId = initialChapterId
 
-        restartableLatestCache(LOAD_ACTIVE_CHAPTER,
-                { loadChapterObservable(chapter) },
-                { view, _ -> view.onChapterReady(this.chapter) },
-                { view, error -> view.onChapterError(error) })
+        val source = sourceManager.getOrStub(manga.source)
+        loader = ChapterLoader(downloadManager, manga, source)
 
-        if (savedState == null) {
-            loadChapter(chapter)
-        }
-    }
+        Observable.just(manga).subscribeLatestCache(ReaderActivity::setManga)
+        viewerChaptersRelay.subscribeLatestCache(ReaderActivity::setChapters)
+        isLoadingAdjacentChapterRelay.subscribeLatestCache(ReaderActivity::setProgressDialog)
 
-    override fun onSave(state: Bundle) {
-        chapter.requestedPage = chapter.last_page_read
-        state.putSerializable(ReaderPresenter::manga.name, manga)
-        state.putSerializable(ReaderPresenter::chapter.name, chapter)
-        super.onSave(state)
-    }
-
-    override fun onDestroy() {
-        loader.cleanup()
-        onChapterLeft()
-        super.onDestroy()
+        // Read chapterList from an io thread because it's retrieved lazily and would block main.
+        activeChapterSubscription?.unsubscribe()
+        activeChapterSubscription = Observable
+            .fromCallable { chapterList.first { chapterId == it.chapter.id } }
+            .flatMap { getLoadObservable(loader!!, it) }
+            .subscribeOn(Schedulers.io())
+            .subscribeFirst({ _, _ ->
+                // Ignore onNext event
+            }, ReaderActivity::setInitialChapterError)
     }
 
     /**
-     * Converts a chapter to a [ReaderChapter] if needed.
+     * Returns an observable that loads the given [chapter] with this [loader]. This observable
+     * handles main thread synchronization and updating the currently active chapters on
+     * [viewerChaptersRelay], however callers must ensure there won't be more than one
+     * subscription active by unsubscribing any existing [activeChapterSubscription] before.
+     * Callers must also handle the onError event.
      */
-    private fun Chapter.toModel(): ReaderChapter {
-        if (this is ReaderChapter) return this
-        return ReaderChapter(this)
+    private fun getLoadObservable(
+            loader: ChapterLoader,
+            chapter: ReaderChapter
+    ): Observable<ViewerChapters> {
+        return loader.loadChapter(chapter)
+            .andThen(Observable.fromCallable {
+                val chapterPos = chapterList.indexOf(chapter)
+
+                ViewerChapters(chapter,
+                        chapterList.getOrNull(chapterPos - 1),
+                        chapterList.getOrNull(chapterPos + 1))
+            })
+            .observeOn(AndroidSchedulers.mainThread())
+            .doOnNext { newChapters ->
+                val oldChapters = viewerChaptersRelay.value
+
+                // Add new references first to avoid unnecessary recycling
+                newChapters.ref()
+                oldChapters?.unref()
+
+                viewerChaptersRelay.call(newChapters)
+            }
     }
 
     /**
-     * Returns an observable that loads the given chapter, discarding any previous work.
-     *
-     * @param chapter the now active chapter.
+     * Called when the user changed to the given [chapter] when changing pages from the viewer.
+     * It's used only to set this chapter as active.
      */
-    private fun loadChapterObservable(chapter: ReaderChapter): Observable<ReaderChapter> {
-        loader.restart()
-        return loader.loadChapter(chapter)
-                .subscribeOn(Schedulers.io())
-                .observeOn(AndroidSchedulers.mainThread())
-                .doOnNext { chapterLoaded = true }
+    private fun loadNewChapter(chapter: ReaderChapter) {
+        val loader = loader ?: return
+
+        Timber.d("Loading ${chapter.chapter.url}")
+
+        activeChapterSubscription?.unsubscribe()
+        activeChapterSubscription = getLoadObservable(loader, chapter)
+            .toCompletable()
+            .onErrorComplete()
+            .subscribe()
+            .also(::add)
     }
 
     /**
-     * Obtains the adjacent chapters of the given one in a background thread, and notifies the view
-     * when they are known.
-     *
-     * @param chapter the current active chapter.
+     * Called when the user is going to load the prev/next chapter through the menu button. It
+     * sets the [isLoadingAdjacentChapterRelay] that the view uses to prevent any further
+     * interaction until the chapter is loaded.
      */
-    private fun getAdjacentChapters(chapter: ReaderChapter) {
-        // Keep only one subscription
-        adjacentChaptersSubscription?.let { remove(it) }
+    private fun loadAdjacent(chapter: ReaderChapter) {
+        val loader = loader ?: return
 
-        adjacentChaptersSubscription = Observable
-                .fromCallable { getAdjacentChaptersStrategy(chapter) }
-                .doOnNext { pair ->
-                    prevChapter = loadedChapters.getOrElse(pair.first?.id) { pair.first }
-                    nextChapter = loadedChapters.getOrElse(pair.second?.id) { pair.second }
-                }
-                .subscribeOn(Schedulers.io())
-                .observeOn(AndroidSchedulers.mainThread())
-                .subscribeLatestCache({ view, pair ->
-                    view.onAdjacentChapters(pair.first, pair.second)
-                })
+        Timber.d("Loading adjacent ${chapter.chapter.url}")
+
+        activeChapterSubscription?.unsubscribe()
+        activeChapterSubscription = getLoadObservable(loader, chapter)
+            .doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) }
+            .doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) }
+            .subscribeFirst({ view, _ ->
+                view.moveToPageIndex(0)
+            }, { _, _ ->
+                // Ignore onError event, viewers handle that state
+            })
     }
 
     /**
-     * Returns the previous and next chapters of the given one in a [Pair] according to the sorting
-     * strategy set for the manga.
-     *
-     * @param chapter the current active chapter.
-     * @param previousChapterAmount the desired number of chapters preceding the current active chapter (Default: 1).
-     * @param nextChapterAmount the desired number of chapters succeeding the current active chapter (Default: 1).
+     * Called when the viewers decide it's a good time to preload a [chapter] and improve the UX so
+     * that the user doesn't have to wait too long to continue reading.
      */
-    private fun getAdjacentChaptersStrategy(chapter: ReaderChapter, previousChapterAmount: Int = 1, nextChapterAmount: Int = 1) = when (manga.sorting) {
-        Manga.SORTING_SOURCE -> {
-            val currChapterIndex = chapterList.indexOfFirst { chapter.id == it.id }
-            val nextChapter = chapterList.getOrNull(currChapterIndex + nextChapterAmount)
-            val prevChapter = chapterList.getOrNull(currChapterIndex - previousChapterAmount)
-            Pair(prevChapter, nextChapter)
+    private fun preload(chapter: ReaderChapter) {
+        if (chapter.state != ReaderChapter.State.Wait && chapter.state !is ReaderChapter.State.Error) {
+            return
         }
-        Manga.SORTING_NUMBER -> {
-            val currChapterIndex = chapterList.indexOfFirst { chapter.id == it.id }
-            val chapterNumber = chapter.chapter_number
-
-            var prevChapter: ReaderChapter? = null
-            for (i in (currChapterIndex - previousChapterAmount) downTo 0) {
-                val c = chapterList[i]
-                if (c.chapter_number < chapterNumber && c.chapter_number >= chapterNumber - previousChapterAmount) {
-                    prevChapter = c
-                    break
-                }
-            }
 
-            var nextChapter: ReaderChapter? = null
-            for (i in (currChapterIndex + nextChapterAmount) until chapterList.size) {
-                val c = chapterList[i]
-                if (c.chapter_number > chapterNumber && c.chapter_number <= chapterNumber + nextChapterAmount) {
-                    nextChapter = c
-                    break
-                }
-            }
-            Pair(prevChapter, nextChapter)
-        }
-        else -> throw NotImplementedError("Unknown sorting method")
+        Timber.d("Preloading ${chapter.chapter.url}")
+
+        val loader = loader ?: return
+
+        loader.loadChapter(chapter)
+            .observeOn(AndroidSchedulers.mainThread())
+            // Update current chapters whenever a chapter is preloaded
+            .doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) }
+            .onErrorComplete()
+            .subscribe()
+            .also(::add)
     }
 
     /**
-     * Loads the given chapter and sets it as the active one. This method also accepts a requested
-     * page, which will be set as active when it's displayed in the view.
-     *
-     * @param chapter the chapter to load.
-     * @param requestedPage the requested page from the view.
+     * Called every time a page changes on the reader. Used to mark the flag of chapters being
+     * read, enqueue downloaded chapter deletion, and updating the active chapter if this
+     * [page]'s chapter is different from the currently active.
      */
-    private fun loadChapter(chapter: ReaderChapter, requestedPage: Int = 0) {
-        // Cleanup any append.
-        appenderSubscription?.let { remove(it) }
+    fun onPageSelected(page: ReaderPage) {
+        val currentChapters = viewerChaptersRelay.value ?: return
 
-        this.chapter = loadedChapters.getOrPut(chapter.id) { chapter }
+        val selectedChapter = page.chapter
 
-        // If the chapter is partially read, set the starting page to the last the user read
-        // otherwise use the requested page.
-        chapter.requestedPage = if (!chapter.read) chapter.last_page_read else requestedPage
-
-        // Reset next and previous chapter. They have to be fetched again
-        nextChapter = null
-        prevChapter = null
+        // Save last page read and mark as read if needed
+        selectedChapter.chapter.last_page_read = page.index
+        if (selectedChapter.pages?.lastIndex == page.index) {
+            selectedChapter.chapter.read = true
+            enqueueDeleteReadChapters(selectedChapter)
+        }
 
-        chapterLoaded = false
-        start(LOAD_ACTIVE_CHAPTER)
-        getAdjacentChapters(chapter)
+        if (selectedChapter != currentChapters.currChapter) {
+            Timber.d("Setting ${selectedChapter.chapter.url} as active")
+            onChapterChanged(currentChapters.currChapter, selectedChapter)
+            loadNewChapter(selectedChapter)
+        }
     }
 
     /**
-     * Changes the active chapter, but doesn't load anything. Called when changing chapters from
-     * the reader with the seamless mode.
-     *
-     * @param chapter the chapter to set as active.
+     * Called when a chapter changed from [fromChapter] to [toChapter]. It updates [fromChapter]
+     * on the database.
      */
-    fun setActiveChapter(chapter: ReaderChapter) {
-        onChapterLeft()
-        this.chapter = chapter
-        nextChapter = null
-        prevChapter = null
-        getAdjacentChapters(chapter)
+    private fun onChapterChanged(fromChapter: ReaderChapter, toChapter: ReaderChapter) {
+        saveChapterProgress(fromChapter)
+        saveChapterHistory(fromChapter)
     }
 
     /**
-     * Appends the next chapter to the reader, if possible.
+     * Saves this [chapter] progress (last read page and whether it's read).
      */
-    fun appendNextChapter() {
-        appenderSubscription?.let { remove(it) }
-
-        val nextChapter = nextChapter ?: return
-        val chapterToLoad = loadedChapters.getOrPut(nextChapter.id) { nextChapter }
-
-        appenderSubscription = loader.loadChapter(chapterToLoad)
-                .subscribeOn(Schedulers.io())
-                .retryWhen(RetryWithDelay(1, { 3000 }))
-                .observeOn(AndroidSchedulers.mainThread())
-                .subscribeLatestCache({ view, chapter ->
-                    view.onAppendChapter(chapter)
-                }, { view, _ ->
-                    view.onChapterAppendError()
-                })
+    private fun saveChapterProgress(chapter: ReaderChapter) {
+        db.updateChapterProgress(chapter.chapter).asRxCompletable()
+            .onErrorComplete()
+            .subscribeOn(Schedulers.io())
+            .subscribe()
     }
 
     /**
-     * Retries a page that failed to load due to network error or corruption.
-     *
-     * @param page the page that failed.
+     * Saves this [chapter] last read history.
      */
-    fun retryPage(page: Page?) {
-        if (page != null && source is HttpSource) {
-            page.status = Page.QUEUE
-            val imageUrl = page.imageUrl
-            if (imageUrl != null && !page.chapter.isDownloaded) {
-                val key = DiskUtil.hashKeyForDisk(page.url)
-                chapterCache.removeFileFromCache(key)
-            }
-            loader.retryPage(page)
-        }
+    private fun saveChapterHistory(chapter: ReaderChapter) {
+        val history = History.create(chapter.chapter).apply { last_read = Date().time }
+        db.updateHistoryLastRead(history).asRxCompletable()
+            .onErrorComplete()
+            .subscribeOn(Schedulers.io())
+            .subscribe()
     }
 
     /**
-     * Called before loading another chapter or leaving the reader. It allows to do operations
-     * over the chapter read like saving progress
+     * Called from the activity to preload the next chapter.
      */
-    fun onChapterLeft() {
-        // Reference these locally because they are needed later from another thread.
-        val chapter = chapter
-
-        val pages = chapter.pages ?: return
-
-        Observable.fromCallable {
-            // Cache current page list progress for online chapters to allow a faster reopen
-            if (!chapter.isDownloaded) {
-                source.let {
-                    if (it is HttpSource) chapterCache.putPageListToCache(chapter, pages)
-                }
-            }
-
-            try {
-                if (chapter.read) {
-                    val removeAfterReadSlots = prefs.removeAfterReadSlots()
-                    when (removeAfterReadSlots) {
-                        // Setting disabled
-                        -1 -> { /* Empty function */ }
-                        // Remove current read chapter
-                        0 -> deleteChapter(chapter, manga)
-                        // Remove previous chapter specified by user in settings.
-                        else -> getAdjacentChaptersStrategy(chapter, removeAfterReadSlots)
-                                .first?.let { deleteChapter(it, manga) }
-                    }
-                }
-            } catch (error: Exception) {
-                // TODO find out why it crashes
-                Timber.e(error)
-            }
-
-            db.updateChapterProgress(chapter).executeAsBlocking()
-
-            try {
-                val history = History.create(chapter).apply { last_read = Date().time }
-                db.updateHistoryLastRead(history).executeAsBlocking()
-            } catch (error: Exception) {
-                // TODO find out why it crashes
-                Timber.e(error)
-            }
-        }
-                .subscribeOn(Schedulers.io())
-                .subscribe()
+    fun preloadNextChapter() {
+        val nextChapter = viewerChaptersRelay.value?.nextChapter ?: return
+        preload(nextChapter)
     }
 
     /**
-     * Called when the active page changes in the reader.
-     *
-     * @param page the active page
+     * Called from the activity to preload the previous chapter.
      */
-    fun onPageChanged(page: Page) {
-        val chapter = page.chapter
-        chapter.last_page_read = page.index
-        if (chapter.pages!!.last() === page) {
-            chapter.read = true
-        }
-        if (!chapter.isDownloaded && page.status == Page.QUEUE) {
-            loader.loadPriorizedPage(page)
-        }
+    fun preloadPreviousChapter() {
+        val prevChapter = viewerChaptersRelay.value?.prevChapter ?: return
+        preload(prevChapter)
     }
 
     /**
-     * Delete selected chapter
-     *
-     * @param chapter chapter that is selected
-     * @param manga manga that belongs to chapter
+     * Called from the activity to load and set the next chapter as active.
      */
-    fun deleteChapter(chapter: ReaderChapter, manga: Manga) {
-        chapter.isDownloaded = false
-        chapter.pages?.forEach { it.status == Page.QUEUE }
-        downloadManager.deleteChapter(chapter, manga, source)
+    fun loadNextChapter() {
+        val nextChapter = viewerChaptersRelay.value?.nextChapter ?: return
+        loadAdjacent(nextChapter)
     }
 
     /**
-     * Returns the chapter to be marked as last read in sync services or 0 if no update required.
+     * Called from the activity to load and set the previous chapter as active.
      */
-    fun getTrackChapterToUpdate(): Int {
-        val trackList = trackList
-        if (chapter.pages == null || trackList == null || trackList.isEmpty())
-            return 0
-
-        val prevChapter = prevChapter
+    fun loadPreviousChapter() {
+        val prevChapter = viewerChaptersRelay.value?.prevChapter ?: return
+        loadAdjacent(prevChapter)
+    }
 
-        // Get the last chapter read from the reader.
-        val lastChapterRead = if (chapter.read)
-            Math.floor(chapter.chapter_number.toDouble()).toInt()
-        else if (prevChapter != null && prevChapter.read)
-            Math.floor(prevChapter.chapter_number.toDouble()).toInt()
-        else
-            return 0
+    /**
+     * Returns the currently active chapter.
+     */
+    fun getCurrentChapter(): ReaderChapter? {
+        return viewerChaptersRelay.value?.currChapter
+    }
 
-        return if (trackList.any { lastChapterRead > it.last_chapter_read })
-            lastChapterRead
-        else
-            0
+    /**
+     * Returns the viewer position used by this manga or the default one.
+     */
+    fun getMangaViewer(): Int {
+        val manga = manga ?: return preferences.defaultViewer()
+        return if (manga.viewer == 0) preferences.defaultViewer() else manga.viewer
     }
 
     /**
-     * Starts the service that updates the last chapter read in sync services
+     * Updates the viewer position for the open manga.
      */
-    fun updateTrackLastChapterRead(lastChapterRead: Int) {
-        trackList?.forEach { track ->
-            val service = trackManager.getService(track.sync_id)
-            if (service != null && service.isLogged && lastChapterRead > track.last_chapter_read) {
-                track.last_chapter_read = lastChapterRead
+    fun setMangaViewer(viewer: Int) {
+        val manga = manga ?: return
+        manga.viewer = viewer
+        // TODO custom put operation
+        db.insertManga(manga).executeAsBlocking()
 
-                // We wan't these to execute even if the presenter is destroyed and leaks for a
-                // while. The view can still be garbage collected.
-                Observable.defer { service.update(track) }
-                        .map { db.insertTrack(track).executeAsBlocking() }
-                        .subscribeOn(Schedulers.io())
-                        .observeOn(AndroidSchedulers.mainThread())
-                        .subscribe({}, { Timber.e(it) })
-            }
-        }
+        Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
+            .subscribeFirst({ view, _ ->
+                val currChapters = viewerChaptersRelay.value
+                if (currChapters != null) {
+                    // Save current page
+                    val currChapter = currChapters.currChapter
+                    currChapter.requestedPage = currChapter.chapter.last_page_read
+
+                    // Emit manga and chapters to the new viewer
+                    view.setManga(manga)
+                    view.setChapters(currChapters)
+                }
+            })
     }
 
     /**
-     * Loads the next chapter.
-     *
-     * @return true if the next chapter is being loaded, false if there is no next chapter.
+     * Saves the image of this [page] in the given [directory] and returns the file location.
      */
-    fun loadNextChapter(): Boolean {
-        // Avoid skipping chapters.
-        if (!chapterLoaded) return true
+    private fun saveImage(page: ReaderPage, directory: File, manga: Manga): File {
+        val stream = page.stream!!
+        val type = ImageUtil.findImageType(stream) ?: throw Exception("Not an image")
+
+        directory.mkdirs()
+
+        val chapter = page.chapter.chapter
+
+        // Build destination file.
+        val filename = DiskUtil.buildValidFilename(
+                "${manga.title} - ${chapter.name}") + " - ${page.number}.${type.extension}"
 
-        nextChapter?.let {
-            onChapterLeft()
-            loadChapter(it, 0)
-            return true
+        val destFile = File(directory, filename)
+        stream().use { input ->
+            destFile.outputStream().use { output ->
+                input.copyTo(output)
+            }
         }
-        return false
+        return destFile
     }
 
     /**
-     * Loads the next chapter.
-     *
-     * @return true if the previous chapter is being loaded, false if there is no previous chapter.
+     * Saves the image of this [page] on the pictures directory and notifies the UI of the result.
+     * There's also a notification to allow sharing the image somewhere else or deleting it.
      */
-    fun loadPreviousChapter(): Boolean {
-        // Avoid skipping chapters.
-        if (!chapterLoaded) return true
+    fun saveImage(page: ReaderPage) {
+        if (page.status != Page.READY) return
+        val manga = manga ?: return
+        val context = Injekt.get<Application>()
 
-        prevChapter?.let {
-            onChapterLeft()
-            loadChapter(it, if (it.read) -1 else 0)
-            return true
-        }
-        return false
+        val notifier = SaveImageNotifier(context)
+        notifier.onClear()
+
+        // Pictures directory.
+        val destDir = File(Environment.getExternalStorageDirectory().absolutePath +
+                           File.separator + Environment.DIRECTORY_PICTURES +
+                           File.separator + "Tachiyomi")
+
+        // Copy file in background.
+        Observable.fromCallable { saveImage(page, destDir, manga) }
+            .doOnNext { file ->
+                DiskUtil.scanMedia(context, file)
+                notifier.onComplete(file)
+            }
+            .doOnError { notifier.onError(it.message) }
+            .subscribeOn(Schedulers.io())
+            .observeOn(AndroidSchedulers.mainThread())
+            .subscribeFirst(
+                    { view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) },
+                    { view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) }
+            )
     }
 
     /**
-     * Returns true if there's a next chapter.
+     * Shares the image of this [page] and notifies the UI with the path of the file to share.
+     * The image must be first copied to the internal partition because there are many possible
+     * formats it can come from, like a zipped chapter, in which case it's not possible to directly
+     * get a path to the file and it has to be decompresssed somewhere first. Only the last shared
+     * image will be kept so it won't be taking lots of internal disk space.
      */
-    fun hasNextChapter(): Boolean {
-        return nextChapter != null
+    fun shareImage(page: ReaderPage) {
+        if (page.status != Page.READY) return
+        val manga = manga ?: return
+        val context = Injekt.get<Application>()
+
+        val destDir = File(context.cacheDir, "shared_image")
+
+        Observable.fromCallable { destDir.delete() } // Keep only the last shared file
+            .map { saveImage(page, destDir, manga) }
+            .subscribeOn(Schedulers.io())
+            .observeOn(AndroidSchedulers.mainThread())
+            .subscribeFirst(
+                    { view, file -> view.onShareImageResult(file) },
+                    { view, error -> /* Empty */ }
+            )
     }
 
     /**
-     * Returns true if there's a previous chapter.
+     * Sets the image of this [page] as cover and notifies the UI of the result.
      */
-    fun hasPreviousChapter(): Boolean {
-        return prevChapter != null
+    fun setAsCover(page: ReaderPage) {
+        if (page.status != Page.READY) return
+        val manga = manga ?: return
+        val stream = page.stream ?: return
+
+        Observable
+            .fromCallable {
+                if (manga.source == LocalSource.ID) {
+                    val context = Injekt.get<Application>()
+                    LocalSource.updateCover(context, manga, stream())
+                    R.string.cover_updated
+                    SetAsCoverResult.Success
+                } else {
+                    val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
+                    if (manga.favorite) {
+                        coverCache.copyToCache(thumbUrl, stream())
+                        SetAsCoverResult.Success
+                    } else {
+                        SetAsCoverResult.AddToLibraryFirst
+                    }
+                }
+            }
+            .subscribeOn(Schedulers.io())
+            .observeOn(AndroidSchedulers.mainThread())
+            .subscribeFirst(
+                    { view, result -> view.onSetAsCoverResult(result) },
+                    { view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) }
+            )
     }
 
     /**
-     * Updates the viewer for this manga.
-     *
-     * @param viewer the id of the viewer to set.
+     * Results of the set as cover feature.
      */
-    fun updateMangaViewer(viewer: Int) {
-        manga.viewer = viewer
-        db.insertManga(manga).executeAsBlocking()
+    enum class SetAsCoverResult {
+        Success, AddToLibraryFirst, Error
     }
 
     /**
-     * Update cover with page file.
+     * Results of the save image feature.
      */
-    internal fun setImageAsCover(page: Page) {
-        try {
-            if (manga.source == LocalSource.ID) {
-                val input = context.contentResolver.openInputStream(page.uri)
-                LocalSource.updateCover(context, manga, input)
-                context.toast(R.string.cover_updated)
-                return
-            }
-
-            val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
-            if (manga.favorite) {
-                val input = context.contentResolver.openInputStream(page.uri)
-                coverCache.copyToCache(thumbUrl, input)
-                context.toast(R.string.cover_updated)
-            } else {
-                context.toast(R.string.notification_first_add_to_library)
-            }
-        } catch (error: Exception) {
-            context.toast(R.string.notification_cover_update_failed)
-            Timber.e(error)
-        }
+    sealed class SaveImageResult {
+        class Success(val file: File) : SaveImageResult()
+        class Error(val error: Throwable) : SaveImageResult()
     }
 
     /**
-     * Save page to local storage.
+     * Starts the service that updates the last chapter read in sync services. This operation
+     * will run in a background thread and errors are ignored.
      */
-    internal fun savePage(page: Page) {
-        if (page.status != Page.READY)
-            return
+    private fun updateTrackLastChapterRead() {
+        if (!preferences.autoUpdateTrack()) return
+        val viewerChapters = viewerChaptersRelay.value ?: return
+        val manga = manga ?: return
 
-        // Used to show image notification.
-        val imageNotifier = SaveImageNotifier(context)
+        val currChapter = viewerChapters.currChapter.chapter
+        val prevChapter = viewerChapters.prevChapter?.chapter
 
-        // Remove the notification if it already exists (user feedback).
-        imageNotifier.onClear()
-
-        // Pictures directory.
-        val pictureDirectory = Environment.getExternalStorageDirectory().absolutePath +
-                File.separator + Environment.DIRECTORY_PICTURES +
-                File.separator + context.getString(R.string.app_name)
+        // Get the last chapter read from the reader.
+        val lastChapterRead = if (currChapter.read)
+            currChapter.chapter_number.toInt()
+        else if (prevChapter != null && prevChapter.read)
+            prevChapter.chapter_number.toInt()
+        else
+            return
 
-        // Copy file in background.
-        Observable
-                .fromCallable {
-                    // Folder where the image will be saved.
-                    val destDir = File(pictureDirectory)
-                    destDir.mkdirs()
-
-                    // Find out file mime type.
-                    val mime = context.contentResolver.getType(page.uri)
-                    ?: context.contentResolver.openInputStream(page.uri).buffered().use {
-                        URLConnection.guessContentTypeFromStream(it)
+        val trackManager = Injekt.get<TrackManager>()
+
+        db.getTracks(manga).asRxSingle()
+            .flatMapCompletable { trackList ->
+                Completable.concat(trackList.map { track ->
+                    val service = trackManager.getService(track.sync_id)
+                    if (service != null && service.isLogged && lastChapterRead > track.last_chapter_read) {
+                        track.last_chapter_read = lastChapterRead
+
+                        // We wan't these to execute even if the presenter is destroyed and leaks
+                        // for a while. The view can still be garbage collected.
+                        Observable.defer { service.update(track) }
+                            .map { db.insertTrack(track).executeAsBlocking() }
+                            .toCompletable()
+                            .onErrorComplete()
+                    } else {
+                        Completable.complete()
                     }
+                })
+            }
+            .onErrorComplete()
+            .subscribeOn(Schedulers.io())
+            .subscribe()
+    }
 
-                    // Build destination file.
-                    val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
-                    val filename = DiskUtil.buildValidFilename(
-                            "${manga.title} - ${chapter.name}") + " - ${page.number}.$ext"
-                    val destFile = File(destDir, filename)
+    /**
+     * Enqueues this [chapter] to be deleted when [deletePendingChapters] is called. The download
+     * manager handles persisting it across process deaths.
+     */
+    private fun enqueueDeleteReadChapters(chapter: ReaderChapter) {
+        if (!chapter.chapter.read || chapter.pageLoader !is DownloadPageLoader) return
+        val manga = manga ?: return
 
-                    context.contentResolver.openInputStream(page.uri).use { input ->
-                        destFile.outputStream().use { output ->
-                            input.copyTo(output)
-                        }
-                    }
+        // Return if the setting is disabled
+        val removeAfterReadSlots = preferences.removeAfterReadSlots()
+        if (removeAfterReadSlots == -1) return
 
-                    DiskUtil.scanMedia(context, destFile)
+        Completable
+            .fromCallable {
+                // Position of the read chapter
+                val position = chapterList.indexOf(chapter)
 
-                    imageNotifier.onComplete(destFile)
+                // Retrieve chapter to delete according to preference
+                val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots)
+                if (chapterToDelete != null) {
+                    downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga)
                 }
-                .subscribeOn(Schedulers.io())
-                .observeOn(AndroidSchedulers.mainThread())
-                .subscribe({
-                    context.toast(R.string.picture_saved)
-                }, { error ->
-                    Timber.e(error)
-                    imageNotifier.onError(error.message)
-                })
+            }
+            .onErrorComplete()
+            .subscribeOn(Schedulers.io())
+            .subscribe()
+    }
+
+    /**
+     * Deletes all the pending chapters. This operation will run in a background thread and errors
+     * are ignored.
+     */
+    private fun deletePendingChapters() {
+        Completable.fromCallable { downloadManager.deletePendingChapters() }
+            .onErrorComplete()
+            .subscribeOn(Schedulers.io())
+            .subscribe()
     }
+
 }

+ 45 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSeekBar.kt

@@ -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)
+    }
+
+}

+ 0 - 119
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsDialog.kt

@@ -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()
-    }
-
-}

+ 104 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt

@@ -0,0 +1,104 @@
+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())
+    }
+
+    /**
+     * 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)
+    }
+
+}

+ 7 - 6
app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt

@@ -16,6 +16,7 @@ import java.io.File
  * Class used to show BigPictureStyle notifications
  */
 class SaveImageNotifier(private val context: Context) {
+
     /**
      * Notification builder.
      */
@@ -35,12 +36,12 @@ class SaveImageNotifier(private val context: Context) {
      */
     fun onComplete(file: File) {
         val bitmap = GlideApp.with(context)
-                .asBitmap()
-                .load(file)
-                .diskCacheStrategy(DiskCacheStrategy.NONE)
-                .skipMemoryCache(true)
-                .submit(720, 1280)
-                .get()
+            .asBitmap()
+            .load(file)
+            .diskCacheStrategy(DiskCacheStrategy.NONE)
+            .skipMemoryCache(true)
+            .submit(720, 1280)
+            .get()
 
         if (bitmap != null) {
             showCompleteNotification(file, bitmap)

+ 84 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt

@@ -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")
+        }
+    }
+
+}

+ 43 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt

@@ -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)
+    }
+
+}

+ 48 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt

@@ -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?
+    }
+
+}

+ 54 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt

@@ -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
+        })
+    }
+
+}

+ 222 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt

@@ -0,0 +1,222 @@
+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) {
+            // TODO check compatibility with ReaderPage
+            Completable.fromAction { chapterCache.putPageListToCache(chapter.chapter, pages) }
+                .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 }
+    }
+}

+ 46 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt

@@ -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) {}
+
+}

+ 89 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt

@@ -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
+    }
+
+}

+ 60 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt

@@ -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
+        })
+    }
+}

+ 33 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ChapterTransition.kt

@@ -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})"
+    }
+
+}

+ 58 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt

@@ -0,0 +1,58 @@
+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.DownloadPageLoader
+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
+
+    val isDownloaded
+        get() = pageLoader is DownloadPageLoader
+
+
+    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()
+    }
+}

+ 15 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt

@@ -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
+
+}

+ 21 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ViewerChapters.kt

@@ -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()
+    }
+
+}

+ 46 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/BaseViewer.kt

@@ -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
+
+}

+ 74 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/GestureDetectorWithLongTap.kt

@@ -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) {
+        }
+    }
+
+}

+ 218 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderProgressBar.kt

@@ -0,0 +1,218 @@
+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 aggregated visibility of this view changes. It also starts of stops the
+     * rotation animation according to [isVisible].
+     */
+    override fun onVisibilityAggregated(isVisible: Boolean) {
+        super.onVisibilityAggregated(isVisible)
+
+        if (isVisible != aggregatedIsVisible) {
+            aggregatedIsVisible = isVisible
+
+            // let's be nice with the UI thread
+            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()
+        }
+    }
+
+}

+ 0 - 253
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/BaseReader.kt

@@ -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.
-     */
-    private 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
-
-}

+ 0 - 40
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/PageDecodeErrorLayout.kt

@@ -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
-        }
-    }
-
-}

+ 0 - 6
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/OnChapterBoundariesOutListener.kt

@@ -1,6 +0,0 @@
-package eu.kanade.tachiyomi.ui.reader.viewer.pager
-
-interface OnChapterBoundariesOutListener {
-    fun onFirstPageOutEvent()
-    fun onLastPageOutEvent()
-}

+ 0 - 276
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PageView.kt

@@ -1,276 +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.inflate
-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.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
-        }
-
-        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)
-                }
-            })
-        }
-
-        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)
-        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
-        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)
-    }
-
-    /**
-     * Called when the page is downloading.
-     */
-    private fun setDownloading() {
-        progress_container.visibility = View.VISIBLE
-        progress_text.visibility = View.VISIBLE
-    }
-
-    /**
-     * 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
-        }
-
-        progress_text.visibility = View.INVISIBLE
-        image_view.setImage(ImageSource.uri(file.uri))
-    }
-
-    /**
-     * Called when the page has an error.
-     */
-    private fun setError() {
-        progress_container.visibility = View.GONE
-        retry_button.visibility = View.VISIBLE
-    }
-
-    /**
-     * 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)
-    }
-
-}

+ 0 - 28
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.java

@@ -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();
-}

+ 109 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.kt

@@ -0,0 +1,109 @@
+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) -> Unit)? = 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
+    }
+
+}

+ 25 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerButton.kt

@@ -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
+        }
+    }
+
+}

+ 107 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt

@@ -0,0 +1,107 @@
+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 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.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
+    }
+
+}

+ 464 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt

@@ -0,0 +1,464 @@
+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.request.RequestListener
+import com.bumptech.glide.request.target.Target
+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.apply { 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)
+            .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)
+    }
+
+}

+ 0 - 326
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReader.kt

@@ -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
-        }
-    }
-
-}

+ 0 - 47
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReaderAdapter.kt

@@ -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
-        }
-    }
-
-}

+ 190 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt

@@ -0,0 +1,190 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.pager
+
+import android.annotation.SuppressLint
+import android.support.v7.widget.AppCompatTextView
+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) {
+            context.getString(R.string.transition_finished, transition.from.chapter.name) + "\n\n" +
+            context.getString(R.string.transition_next, 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) {
+            context.getString(R.string.transition_current, transition.from.chapter.name) + "\n\n" +
+            context.getString(R.string.transition_previous, 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 {
+                if (transition is ChapterTransition.Next) {
+                    viewer.activity.requestPreloadNextChapter()
+                } else {
+                    viewer.activity.requestPreloadPreviousChapter()
+                }
+            }
+        }
+
+        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)
+    }
+
+}

+ 311 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt

@@ -0,0 +1,311 @@
+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)
+                        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 = {
+            val item = adapter.items.getOrNull(pager.currentItem)
+            if (item is ReaderPage) {
+                activity.onPageLongTap(item)
+            }
+        }
+
+        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) {
+        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")
+            activity.requestPreloadNextChapter()
+        }
+    }
+
+    /**
+     * 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")
+        when (transition) {
+            is ChapterTransition.Prev -> {
+                Timber.d("Request preload previous chapter because we're on the transition")
+                activity.requestPreloadPreviousChapter()
+            }
+            is ChapterTransition.Next -> {
+                Timber.d("Request preload next chapter because we're on the transition")
+                activity.requestPreloadNextChapter()
+            }
+        }
+    }
+
+    /**
+     * 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 (activity.menuVisible) {
+                    return false
+                } else if (config.volumeKeysEnabled && isUp) {
+                    if (!config.volumeKeysInverted) moveDown() else moveUp()
+                }
+            }
+            KeyEvent.KEYCODE_VOLUME_UP -> {
+                if (activity.menuVisible) {
+                    return false
+                } else if (config.volumeKeysEnabled && 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
+    }
+
+}

+ 101 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt

@@ -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
+    }
+
+}

+ 53 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewers.kt

@@ -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)
+    }
+}

+ 0 - 86
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/horizontal/HorizontalPager.kt

@@ -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)
-            }
-        })
-    }
-
-}

+ 0 - 19
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/horizontal/LeftToRightReader.kt

@@ -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) }
-    }
-
-}

+ 0 - 50
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/horizontal/RightToLeftReader.kt

@@ -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()
-    }
-
-}

+ 0 - 84
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/vertical/VerticalPager.kt

@@ -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
-    }
-
-}

+ 0 - 19
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/vertical/VerticalReader.kt

@@ -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) }
-    }
-
-}

+ 0 - 2990
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/vertical/VerticalViewPagerImpl.java

@@ -1,2990 +0,0 @@
-/*
- * Copyright (C) 2011 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package eu.kanade.tachiyomi.ui.reader.viewer.pager.vertical;
-
-import android.content.Context;
-import android.content.res.Resources;
-import android.content.res.TypedArray;
-import android.database.DataSetObserver;
-import android.graphics.Canvas;
-import android.graphics.Rect;
-import android.graphics.drawable.Drawable;
-import android.os.Build;
-import android.os.Bundle;
-import android.os.Parcel;
-import android.os.Parcelable;
-import android.os.SystemClock;
-import android.support.annotation.CallSuper;
-import android.support.annotation.DrawableRes;
-import android.support.v4.os.ParcelableCompat;
-import android.support.v4.os.ParcelableCompatCreatorCallbacks;
-import android.support.v4.view.AccessibilityDelegateCompat;
-import android.support.v4.view.MotionEventCompat;
-import android.support.v4.view.PagerAdapter;
-import android.support.v4.view.VelocityTrackerCompat;
-import android.support.v4.view.ViewCompat;
-import android.support.v4.view.ViewConfigurationCompat;
-import android.support.v4.view.accessibility.AccessibilityEventCompat;
-import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
-import android.support.v4.view.accessibility.AccessibilityRecordCompat;
-import android.support.v4.widget.EdgeEffectCompat;
-import android.util.AttributeSet;
-import android.util.Log;
-import android.view.FocusFinder;
-import android.view.Gravity;
-import android.view.KeyEvent;
-import android.view.MotionEvent;
-import android.view.SoundEffectConstants;
-import android.view.VelocityTracker;
-import android.view.View;
-import android.view.ViewConfiguration;
-import android.view.ViewGroup;
-import android.view.ViewParent;
-import android.view.accessibility.AccessibilityEvent;
-import android.view.animation.Interpolator;
-import android.widget.Scroller;
-
-import java.lang.reflect.Method;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-
-/**
- * Layout manager that allows the user to flip left and right
- * through pages of data.  You supply an implementation of a
- * {@link PagerAdapter} to generate the pages that the view shows.
- *
- * <p>Note this class is currently under early design and
- * development.  The API will likely change in later updates of
- * the compatibility library, requiring changes to the source code
- * of apps when they are compiled against the newer version.</p>
- *
- * <p>ViewPager is most often used in conjunction with {@link android.app.Fragment},
- * which is a convenient way to supply and manage the lifecycle of each page.
- * There are standard adapters implemented for using fragments with the ViewPager,
- * which cover the most common use cases.  These are
- * {@link android.support.v4.app.FragmentPagerAdapter} and 
- * {@link android.support.v4.app.FragmentStatePagerAdapter}; each of these
- * classes have simple code showing how to build a full user interface
- * with them.
- *
- * <p>For more information about how to use ViewPager, read <a
- * href="{@docRoot}training/implementing-navigation/lateral.html">Creating Swipe Views with
- * Tabs</a>.</p>
- *
- * <p>Below is a more complicated example of ViewPager, using it in conjunction
- * with {@link android.app.ActionBar} tabs.  You can find other examples of using
- * ViewPager in the API 4+ Support Demos and API 13+ Support Demos sample code.
- *
- * {@sample development/samples/Support13Demos/src/com/example/android/supportv13/app/ActionBarTabsPager.java
- *      complete}
- */
-@SuppressWarnings("deprecation")
-public class VerticalViewPagerImpl extends ViewGroup {
-    private static final String TAG = "ViewPager";
-    private static final boolean DEBUG = false;
-
-    private static final boolean USE_CACHE = false;
-
-    private static final int DEFAULT_OFFSCREEN_PAGES = 1;
-    private static final int MAX_SETTLE_DURATION = 600; // ms
-    private static final int MIN_DISTANCE_FOR_FLING = 25; // dips
-
-    private static final int DEFAULT_GUTTER_SIZE = 16; // dips
-
-    private static final int MIN_FLING_VELOCITY = 400; // dips
-
-    private static final int[] LAYOUT_ATTRS = new int[] {
-            android.R.attr.layout_gravity
-    };
-
-    /**
-     * Used to track what the expected number of items in the adapter should be.
-     * If the app changes this when we don't expect it, we'll throw a big obnoxious exception.
-     */
-    private int mExpectedAdapterCount;
-
-    static class ItemInfo {
-        private Object object;
-        private int position;
-        private boolean scrolling;
-        private float heightFactor;
-        private float offset;
-    }
-
-    private static final Comparator<ItemInfo> COMPARATOR = new Comparator<ItemInfo>(){
-        @Override
-        public int compare(ItemInfo lhs, ItemInfo rhs) {
-            return lhs.position - rhs.position;
-        }
-    };
-
-    private static final Interpolator sInterpolator = new Interpolator() {
-        public float getInterpolation(float t) {
-            t -= 1.0f;
-            return t * t * t * t * t + 1.0f;
-        }
-    };
-
-    private final ArrayList<ItemInfo> mItems = new ArrayList<ItemInfo>();
-    private final ItemInfo mTempItem = new ItemInfo();
-
-    private final Rect mTempRect = new Rect();
-
-    private PagerAdapter mAdapter;
-    private int mCurItem;   // Index of currently displayed page.
-    private int mRestoredCurItem = -1;
-    private Parcelable mRestoredAdapterState = null;
-    private ClassLoader mRestoredClassLoader = null;
-    private Scroller mScroller;
-    private PagerObserver mObserver;
-
-    private int mPageMargin;
-    private Drawable mMarginDrawable;
-    private int mLeftPageBounds;
-    private int mRightPageBounds;
-
-    // Offsets of the first and last items, if known.
-    // Set during population, used to determine if we are at the beginning
-    // or end of the pager data set during touch scrolling.
-    private float mFirstOffset = -Float.MAX_VALUE;
-    private float mLastOffset = Float.MAX_VALUE;
-
-    private int mChildWidthMeasureSpec;
-    private int mChildHeightMeasureSpec;
-    private boolean mInLayout;
-
-    private boolean mScrollingCacheEnabled;
-
-    private boolean mPopulatePending;
-    private int mOffscreenPageLimit = DEFAULT_OFFSCREEN_PAGES;
-
-    private boolean mIsBeingDragged;
-    private boolean mIsUnableToDrag;
-    private int mDefaultGutterSize;
-    private int mGutterSize;
-    private int mTouchSlop;
-    /**
-     * Position of the last motion event.
-     */
-    private float mLastMotionX;
-    private float mLastMotionY;
-    private float mInitialMotionX;
-    private float mInitialMotionY;
-    /**
-     * ID of the active pointer. This is used to retain consistency during
-     * drags/flings if multiple pointers are used.
-     */
-    private int mActivePointerId = INVALID_POINTER;
-    /**
-     * Sentinel value for no current active pointer.
-     * Used by {@link #mActivePointerId}.
-     */
-    private static final int INVALID_POINTER = -1;
-
-    /**
-     * Determines speed during touch scrolling
-     */
-    private VelocityTracker mVelocityTracker;
-    private int mMinimumVelocity;
-    private int mMaximumVelocity;
-    private int mFlingDistance;
-    private int mCloseEnough;
-
-    // If the pager is at least this close to its final position, complete the scroll
-    // on touch down and let the user interact with the content inside instead of
-    // "catching" the flinging pager.
-    private static final int CLOSE_ENOUGH = 2; // dp
-
-    private boolean mFakeDragging;
-    private long mFakeDragBeginTime;
-
-    private EdgeEffectCompat mTopEdge;
-    private EdgeEffectCompat mBottomEdge;
-
-    private boolean mFirstLayout = true;
-    private boolean mNeedCalculatePageOffsets = false;
-    private boolean mCalledSuper;
-    private int mDecorChildCount;
-
-    private List<OnPageChangeListener> mOnPageChangeListeners;
-    private OnPageChangeListener mOnPageChangeListener;
-    private OnPageChangeListener mInternalPageChangeListener;
-    private OnAdapterChangeListener mAdapterChangeListener;
-    private PageTransformer mPageTransformer;
-    private Method mSetChildrenDrawingOrderEnabled;
-
-    private static final int DRAW_ORDER_DEFAULT = 0;
-    private static final int DRAW_ORDER_FORWARD = 1;
-    private static final int DRAW_ORDER_REVERSE = 2;
-    private int mDrawingOrder;
-    private ArrayList<View> mDrawingOrderedChildren;
-    private static final ViewPositionComparator sPositionComparator = new ViewPositionComparator();
-
-    /**
-     * Indicates that the pager is in an idle, settled state. The current page
-     * is fully in view and no animation is in progress.
-     */
-    public static final int SCROLL_STATE_IDLE = 0;
-
-    /**
-     * Indicates that the pager is currently being dragged by the user.
-     */
-    public static final int SCROLL_STATE_DRAGGING = 1;
-
-    /**
-     * Indicates that the pager is in the process of settling to a final position.
-     */
-    public static final int SCROLL_STATE_SETTLING = 2;
-
-    private final Runnable mEndScrollRunnable = new Runnable() {
-        public void run() {
-            setScrollState(SCROLL_STATE_IDLE);
-            populate();
-        }
-    };
-
-    private int mScrollState = SCROLL_STATE_IDLE;
-
-    /**
-     * Callback interface for responding to changing state of the selected page.
-     */
-    public interface OnPageChangeListener {
-
-        /**
-         * This method will be invoked when the current page is scrolled, either as part
-         * of a programmatically initiated smooth scroll or a user initiated touch scroll.
-         *
-         * @param position Position index of the first page currently being displayed.
-         *                 Page position+1 will be visible if positionOffset is nonzero.
-         * @param positionOffset Value from [0, 1) indicating the offset from the page at position.
-         * @param positionOffsetPixels Value in pixels indicating the offset from position.
-         */
-        public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);
-
-        /**
-         * This method will be invoked when a new page becomes selected. Animation is not
-         * necessarily complete.
-         *
-         * @param position Position index of the new selected page.
-         */
-        public void onPageSelected(int position);
-
-        /**
-         * Called when the scroll state changes. Useful for discovering when the user
-         * begins dragging, when the pager is automatically settling to the current page,
-         * or when it is fully stopped/idle.
-         *
-         * @param state The new scroll state.
-         * @see VerticalViewPagerImpl#SCROLL_STATE_IDLE
-         * @see VerticalViewPagerImpl#SCROLL_STATE_DRAGGING
-         * @see VerticalViewPagerImpl#SCROLL_STATE_SETTLING
-         */
-        public void onPageScrollStateChanged(int state);
-    }
-
-    /**
-     * Simple implementation of the {@link OnPageChangeListener} interface with stub
-     * implementations of each method. Extend this if you do not intend to override
-     * every method of {@link OnPageChangeListener}.
-     */
-    public static class SimpleOnPageChangeListener implements OnPageChangeListener {
-        @Override
-        public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
-            // This space for rent
-        }
-
-        @Override
-        public void onPageSelected(int position) {
-            // This space for rent
-        }
-
-        @Override
-        public void onPageScrollStateChanged(int state) {
-            // This space for rent
-        }
-    }
-
-    /**
-     * A PageTransformer is invoked whenever a visible/attached page is scrolled.
-     * This offers an opportunity for the application to apply a custom transformation
-     * to the page views using animation properties.
-     *
-     * <p>As property animation is only supported as of Android 3.0 and forward,
-     * setting a PageTransformer on a ViewPager on earlier platform versions will
-     * be ignored.</p>
-     */
-    public interface PageTransformer {
-        /**
-         * Apply a property transformation to the given page.
-         *
-         * @param page Apply the transformation to this page
-         * @param position Position of page relative to the current front-and-center
-         *                 position of the pager. 0 is front and center. 1 is one full
-         *                 page position to the right, and -1 is one page position to the left.
-         */
-        public void transformPage(View page, float position);
-    }
-
-    /**
-     * Used internally to monitor when adapters are switched.
-     */
-    interface OnAdapterChangeListener {
-        public void onAdapterChanged(PagerAdapter oldAdapter, PagerAdapter newAdapter);
-    }
-
-    /**
-     * Used internally to tag special types of child views that should be added as
-     * pager decorations by default.
-     */
-    interface Decor {}
-
-    public VerticalViewPagerImpl(Context context) {
-        super(context);
-        initViewPager();
-    }
-
-    public VerticalViewPagerImpl(Context context, AttributeSet attrs) {
-        super(context, attrs);
-        initViewPager();
-    }
-
-    void initViewPager() {
-        setWillNotDraw(false);
-        setDescendantFocusability(FOCUS_AFTER_DESCENDANTS);
-        setFocusable(true);
-        final Context context = getContext();
-        mScroller = new Scroller(context, sInterpolator);
-        final ViewConfiguration configuration = ViewConfiguration.get(context);
-        final float density = context.getResources().getDisplayMetrics().density;
-
-        mTouchSlop = ViewConfigurationCompat.getScaledPagingTouchSlop(configuration);
-        mMinimumVelocity = (int) (MIN_FLING_VELOCITY * density);
-        mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
-        mTopEdge = new EdgeEffectCompat(context);
-        mBottomEdge = new EdgeEffectCompat(context);
-
-        mFlingDistance = (int) (MIN_DISTANCE_FOR_FLING * density);
-        mCloseEnough = (int) (CLOSE_ENOUGH * density);
-        mDefaultGutterSize = (int) (DEFAULT_GUTTER_SIZE * density);
-
-        ViewCompat.setAccessibilityDelegate(this, new MyAccessibilityDelegate());
-
-        if (ViewCompat.getImportantForAccessibility(this)
-                == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
-            ViewCompat.setImportantForAccessibility(this,
-                    ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
-        }
-    }
-
-    @Override
-    protected void onDetachedFromWindow() {
-        removeCallbacks(mEndScrollRunnable);
-        super.onDetachedFromWindow();
-    }
-
-    private void setScrollState(int newState) {
-        if (mScrollState == newState) {
-            return;
-        }
-
-        mScrollState = newState;
-        if (mPageTransformer != null) {
-            // PageTransformers can do complex things that benefit from hardware layers.
-            enableLayers(newState != SCROLL_STATE_IDLE);
-        }
-        dispatchOnScrollStateChanged(newState);
-    }
-
-    /**
-     * Set a PagerAdapter that will supply views for this pager as needed.
-     *
-     * @param adapter Adapter to use
-     */
-    public void setAdapter(PagerAdapter adapter) {
-        if (mAdapter != null) {
-            mAdapter.unregisterDataSetObserver(mObserver);
-            mAdapter.startUpdate(this);
-            for (int i = 0; i < mItems.size(); i++) {
-                final ItemInfo ii = mItems.get(i);
-                mAdapter.destroyItem(this, ii.position, ii.object);
-            }
-            mAdapter.finishUpdate(this);
-            mItems.clear();
-            removeNonDecorViews();
-            mCurItem = 0;
-            scrollTo(0, 0);
-        }
-
-        final PagerAdapter oldAdapter = mAdapter;
-        mAdapter = adapter;
-        mExpectedAdapterCount = 0;
-
-        if (mAdapter != null) {
-            if (mObserver == null) {
-                mObserver = new PagerObserver();
-            }
-            mAdapter.registerDataSetObserver(mObserver);
-            mPopulatePending = false;
-            final boolean wasFirstLayout = mFirstLayout;
-            mFirstLayout = true;
-            mExpectedAdapterCount = mAdapter.getCount();
-            if (mRestoredCurItem >= 0) {
-                mAdapter.restoreState(mRestoredAdapterState, mRestoredClassLoader);
-                setCurrentItemInternal(mRestoredCurItem, false, true);
-                mRestoredCurItem = -1;
-                mRestoredAdapterState = null;
-                mRestoredClassLoader = null;
-            } else if (!wasFirstLayout) {
-                populate();
-            } else {
-                requestLayout();
-            }
-        }
-
-        if (mAdapterChangeListener != null && oldAdapter != adapter) {
-            mAdapterChangeListener.onAdapterChanged(oldAdapter, adapter);
-        }
-    }
-
-    private void removeNonDecorViews() {
-        for (int i = 0; i < getChildCount(); i++) {
-            final View child = getChildAt(i);
-            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
-            if (!lp.isDecor) {
-                removeViewAt(i);
-                i--;
-            }
-        }
-    }
-
-    /**
-     * Retrieve the current adapter supplying pages.
-     *
-     * @return The currently registered PagerAdapter
-     */
-    public PagerAdapter getAdapter() {
-        return mAdapter;
-    }
-
-    void setOnAdapterChangeListener(OnAdapterChangeListener listener) {
-        mAdapterChangeListener = listener;
-    }
-
-//    private int getClientWidth() {
-//        return getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
-//    }
-
-    private int getClientHeight() {
-        return getMeasuredHeight() - getPaddingTop() - getPaddingBottom();
-    }
-
-    /**
-     * Set the currently selected page. If the ViewPager has already been through its first
-     * layout with its current adapter there will be a smooth animated transition between
-     * the current item and the specified item.
-     *
-     * @param item Item index to select
-     */
-    public void setCurrentItem(int item) {
-        mPopulatePending = false;
-        setCurrentItemInternal(item, !mFirstLayout, false);
-    }
-
-    /**
-     * Set the currently selected page.
-     *
-     * @param item Item index to select
-     * @param smoothScroll True to smoothly scroll to the new item, false to transition immediately
-     */
-    public void setCurrentItem(int item, boolean smoothScroll) {
-        mPopulatePending = false;
-        setCurrentItemInternal(item, smoothScroll, false);
-    }
-
-    public int getCurrentItem() {
-        return mCurItem;
-    }
-
-    void setCurrentItemInternal(int item, boolean smoothScroll, boolean always) {
-        setCurrentItemInternal(item, smoothScroll, always, 0);
-    }
-
-    void setCurrentItemInternal(int item, boolean smoothScroll, boolean always, int velocity) {
-        if (mAdapter == null || mAdapter.getCount() <= 0) {
-            setScrollingCacheEnabled(false);
-            return;
-        }
-        if (!always && mCurItem == item && !mItems.isEmpty()) {
-            setScrollingCacheEnabled(false);
-            return;
-        }
-
-        if (item < 0) {
-            item = 0;
-        } else if (item >= mAdapter.getCount()) {
-            item = mAdapter.getCount() - 1;
-        }
-        final int pageLimit = mOffscreenPageLimit;
-        if (item > (mCurItem + pageLimit) || item < (mCurItem - pageLimit)) {
-            // We are doing a jump by more than one page.  To avoid
-            // glitches, we want to keep all current pages in the view
-            // until the scroll ends.
-            for (int i=0; i<mItems.size(); i++) {
-                mItems.get(i).scrolling = true;
-            }
-        }
-        final boolean dispatchSelected = mCurItem != item;
-
-        if (mFirstLayout) {
-            // We don't have any idea how big we are yet and shouldn't have any pages either.
-            // Just set things up and let the pending layout handle things.
-            mCurItem = item;
-            if (dispatchSelected) {
-                dispatchOnPageSelected(item);
-            }
-            requestLayout();
-        } else {
-            populate(item);
-            scrollToItem(item, smoothScroll, velocity, dispatchSelected);
-        }
-    }
-
-    private void scrollToItem(int item, boolean smoothScroll, int velocity,
-                              boolean dispatchSelected) {
-        final ItemInfo curInfo = infoForPosition(item);
-        int destY = 0;
-        if (curInfo != null) {
-            final int height = getClientHeight();
-            destY = (int) (height * Math.max(mFirstOffset,
-                    Math.min(curInfo.offset, mLastOffset)));
-        }
-        if (smoothScroll) {
-            smoothScrollTo(0, destY, velocity);
-            if (dispatchSelected) {
-                dispatchOnPageSelected(item);
-            }
-        } else {
-            if (dispatchSelected) {
-                dispatchOnPageSelected(item);
-            }
-            completeScroll(false);
-            scrollTo(0, destY);
-            pageScrolled(destY);
-        }
-    }
-
-    /**
-     * Set a listener that will be invoked whenever the page changes or is incrementally
-     * scrolled. See {@link OnPageChangeListener}.
-     *
-     * @param listener Listener to set
-     *
-     * @deprecated Use {@link #addOnPageChangeListener(OnPageChangeListener)}
-     * and {@link #removeOnPageChangeListener(OnPageChangeListener)} instead.
-     */
-    @Deprecated
-    public void setOnPageChangeListener(OnPageChangeListener listener) {
-        mOnPageChangeListener = listener;
-    }
-
-    /**
-     * Add a listener that will be invoked whenever the page changes or is incrementally
-     * scrolled. See {@link OnPageChangeListener}.
-     *
-     * <p>Components that add a listener should take care to remove it when finished.
-     * Other components that take ownership of a view may call {@link #clearOnPageChangeListeners()}
-     * to remove all attached listeners.</p>
-     *
-     * @param listener listener to add
-     */
-    public void addOnPageChangeListener(OnPageChangeListener listener) {
-        if (mOnPageChangeListeners == null) {
-            mOnPageChangeListeners = new ArrayList<>();
-        }
-        mOnPageChangeListeners.add(listener);
-    }
-
-    /**
-     * Remove a listener that was previously added via
-     * {@link #addOnPageChangeListener(OnPageChangeListener)}.
-     *
-     * @param listener listener to remove
-     */
-    public void removeOnPageChangeListener(OnPageChangeListener listener) {
-        if (mOnPageChangeListeners != null) {
-            mOnPageChangeListeners.remove(listener);
-        }
-    }
-
-    /**
-     * Remove all listeners that are notified of any changes in scroll state or position.
-     */
-    public void clearOnPageChangeListeners() {
-        if (mOnPageChangeListeners != null) {
-            mOnPageChangeListeners.clear();
-        }
-    }
-
-    /**
-     * Set a {@link PageTransformer} that will be called for each attached page whenever
-     * the scroll position is changed. This allows the application to apply custom property
-     * transformations to each page, overriding the default sliding look and feel.
-     *
-     * <p><em>Note:</em> Prior to Android 3.0 the property animation APIs did not exist.
-     * As a result, setting a PageTransformer prior to Android 3.0 (API 11) will have no effect.</p>
-     *
-     * @param reverseDrawingOrder true if the supplied PageTransformer requires page views
-     *                            to be drawn from last to first instead of first to last.
-     * @param transformer PageTransformer that will modify each page's animation properties
-     */
-    public void setPageTransformer(boolean reverseDrawingOrder, PageTransformer transformer) {
-        if (Build.VERSION.SDK_INT >= 11) {
-            final boolean hasTransformer = transformer != null;
-            final boolean needsPopulate = hasTransformer != (mPageTransformer != null);
-            mPageTransformer = transformer;
-            setChildrenDrawingOrderEnabledCompat(hasTransformer);
-            if (hasTransformer) {
-                mDrawingOrder = reverseDrawingOrder ? DRAW_ORDER_REVERSE : DRAW_ORDER_FORWARD;
-            } else {
-                mDrawingOrder = DRAW_ORDER_DEFAULT;
-            }
-            if (needsPopulate) populate();
-        }
-    }
-
-    void setChildrenDrawingOrderEnabledCompat(boolean enable) {
-        if (Build.VERSION.SDK_INT >= 7) {
-            if (mSetChildrenDrawingOrderEnabled == null) {
-                try {
-                    mSetChildrenDrawingOrderEnabled = ViewGroup.class.getDeclaredMethod(
-                            "setChildrenDrawingOrderEnabled", new Class[] { Boolean.TYPE });
-                } catch (NoSuchMethodException e) {
-                    Log.e(TAG, "Can't find setChildrenDrawingOrderEnabled", e);
-                }
-            }
-            try {
-                mSetChildrenDrawingOrderEnabled.invoke(this, enable);
-            } catch (Exception e) {
-                Log.e(TAG, "Error changing children drawing order", e);
-            }
-        }
-    }
-
-    @Override
-    protected int getChildDrawingOrder(int childCount, int i) {
-        final int index = mDrawingOrder == DRAW_ORDER_REVERSE ? childCount - 1 - i : i;
-        final int result = ((LayoutParams) mDrawingOrderedChildren.get(index).getLayoutParams()).childIndex;
-        return result;
-    }
-
-    /**
-     * Set a separate OnPageChangeListener for internal use by the support library.
-     *
-     * @param listener Listener to set
-     * @return The old listener that was set, if any.
-     */
-    OnPageChangeListener setInternalPageChangeListener(OnPageChangeListener listener) {
-        OnPageChangeListener oldListener = mInternalPageChangeListener;
-        mInternalPageChangeListener = listener;
-        return oldListener;
-    }
-
-    /**
-     * Returns the number of pages that will be retained to either side of the
-     * current page in the view hierarchy in an idle state. Defaults to 1.
-     *
-     * @return How many pages will be kept offscreen on either side
-     * @see #setOffscreenPageLimit(int)
-     */
-    public int getOffscreenPageLimit() {
-        return mOffscreenPageLimit;
-    }
-
-    /**
-     * Set the number of pages that should be retained to either side of the
-     * current page in the view hierarchy in an idle state. Pages beyond this
-     * limit will be recreated from the adapter when needed.
-     *
-     * <p>This is offered as an optimization. If you know in advance the number
-     * of pages you will need to support or have lazy-loading mechanisms in place
-     * on your pages, tweaking this setting can have benefits in perceived smoothness
-     * of paging animations and interaction. If you have a small number of pages (3-4)
-     * that you can keep active all at once, less time will be spent in layout for
-     * newly created view subtrees as the user pages back and forth.</p>
-     *
-     * <p>You should keep this limit low, especially if your pages have complex layouts.
-     * This setting defaults to 1.</p>
-     *
-     * @param limit How many pages will be kept offscreen in an idle state.
-     */
-    public void setOffscreenPageLimit(int limit) {
-        if (limit < DEFAULT_OFFSCREEN_PAGES) {
-            Log.w(TAG, "Requested offscreen page limit " + limit + " too small; defaulting to " +
-                    DEFAULT_OFFSCREEN_PAGES);
-            limit = DEFAULT_OFFSCREEN_PAGES;
-        }
-        if (limit != mOffscreenPageLimit) {
-            mOffscreenPageLimit = limit;
-            populate();
-        }
-    }
-
-    /**
-     * Set the margin between pages.
-     *
-     * @param marginPixels Distance between adjacent pages in pixels
-     * @see #getPageMargin()
-     * @see #setPageMarginDrawable(Drawable)
-     * @see #setPageMarginDrawable(int)
-     */
-    public void setPageMargin(int marginPixels) {
-        final int oldMargin = mPageMargin;
-        mPageMargin = marginPixels;
-
-        final int height = getHeight();
-        recomputeScrollPosition(height, height, marginPixels, oldMargin);
-
-        requestLayout();
-    }
-
-    /**
-     * Return the margin between pages.
-     *
-     * @return The size of the margin in pixels
-     */
-    public int getPageMargin() {
-        return mPageMargin;
-    }
-
-    /**
-     * Set a drawable that will be used to fill the margin between pages.
-     *
-     * @param d Drawable to display between pages
-     */
-    public void setPageMarginDrawable(Drawable d) {
-        mMarginDrawable = d;
-        if (d != null) refreshDrawableState();
-        setWillNotDraw(d == null);
-        invalidate();
-    }
-
-    /**
-     * Set a drawable that will be used to fill the margin between pages.
-     *
-     * @param resId Resource ID of a drawable to display between pages
-     */
-    public void setPageMarginDrawable(@DrawableRes int resId) {
-        setPageMarginDrawable(getContext().getResources().getDrawable(resId));
-    }
-
-    @Override
-    protected boolean verifyDrawable(Drawable who) {
-        return super.verifyDrawable(who) || who == mMarginDrawable;
-    }
-
-    @Override
-    protected void drawableStateChanged() {
-        super.drawableStateChanged();
-        final Drawable d = mMarginDrawable;
-        if (d != null && d.isStateful()) {
-            d.setState(getDrawableState());
-        }
-    }
-
-    // We want the duration of the page snap animation to be influenced by the distance that
-    // the screen has to travel, however, we don't want this duration to be effected in a
-    // purely linear fashion. Instead, we use this method to moderate the effect that the distance
-    // of travel has on the overall snap duration.
-    float distanceInfluenceForSnapDuration(float f) {
-        f -= 0.5f; // center the values about 0.
-        f *= 0.3f * Math.PI / 2.0f;
-        return (float) Math.sin(f);
-    }
-
-    /**
-     * Like {@link View#scrollBy}, but scroll smoothly instead of immediately.
-     *
-     * @param x the number of pixels to scroll by on the X axis
-     * @param y the number of pixels to scroll by on the Y axis
-     */
-    void smoothScrollTo(int x, int y) {
-        smoothScrollTo(x, y, 0);
-    }
-
-    /**
-     * Like {@link View#scrollBy}, but scroll smoothly instead of immediately.
-     *
-     * @param x the number of pixels to scroll by on the X axis
-     * @param y the number of pixels to scroll by on the Y axis
-     * @param velocity the velocity associated with a fling, if applicable. (0 otherwise)
-     */
-    void smoothScrollTo(int x, int y, int velocity) {
-        if (getChildCount() == 0) {
-            // Nothing to do.
-            setScrollingCacheEnabled(false);
-            return;
-        }
-        int sx = getScrollX();
-        int sy = getScrollY();
-        int dx = x - sx;
-        int dy = y - sy;
-        if (dx == 0 && dy == 0) {
-            completeScroll(false);
-            populate();
-            setScrollState(SCROLL_STATE_IDLE);
-            return;
-        }
-
-        setScrollingCacheEnabled(true);
-        setScrollState(SCROLL_STATE_SETTLING);
-
-        final int height = getClientHeight();
-        final int halfHeight = height / 2;
-        final float distanceRatio = Math.min(1f, 1.0f * Math.abs(dx) / height);
-        final float distance = halfHeight + halfHeight *
-                distanceInfluenceForSnapDuration(distanceRatio);
-
-        int duration;
-        velocity = Math.abs(velocity);
-        if (velocity > 0) {
-            duration = 4 * Math.round(1000 * Math.abs(distance / velocity));
-        } else {
-            final float pageHeight = height * mAdapter.getPageWidth(mCurItem);
-            final float pageDelta = (float) Math.abs(dx) / (pageHeight + mPageMargin);
-            duration = (int) ((pageDelta + 1) * 100);
-        }
-        duration = Math.min(duration, MAX_SETTLE_DURATION);
-
-        mScroller.startScroll(sx, sy, dx, dy, duration);
-        ViewCompat.postInvalidateOnAnimation(this);
-    }
-
-    ItemInfo addNewItem(int position, int index) {
-        ItemInfo ii = new ItemInfo();
-        ii.position = position;
-        ii.object = mAdapter.instantiateItem(this, position);
-        ii.heightFactor = mAdapter.getPageWidth(position);
-        if (index < 0 || index >= mItems.size()) {
-            mItems.add(ii);
-        } else {
-            mItems.add(index, ii);
-        }
-        return ii;
-    }
-
-    void dataSetChanged() {
-        // This method only gets called if our observer is attached, so mAdapter is non-null.
-
-        final int adapterCount = mAdapter.getCount();
-        mExpectedAdapterCount = adapterCount;
-        boolean needPopulate = mItems.size() < mOffscreenPageLimit * 2 + 1 &&
-                mItems.size() < adapterCount;
-        int newCurrItem = mCurItem;
-
-        boolean isUpdating = false;
-        for (int i = 0; i < mItems.size(); i++) {
-            final ItemInfo ii = mItems.get(i);
-            final int newPos = mAdapter.getItemPosition(ii.object);
-
-            if (newPos == PagerAdapter.POSITION_UNCHANGED) {
-                continue;
-            }
-
-            if (newPos == PagerAdapter.POSITION_NONE) {
-                mItems.remove(i);
-                i--;
-
-                if (!isUpdating) {
-                    mAdapter.startUpdate(this);
-                    isUpdating = true;
-                }
-
-                mAdapter.destroyItem(this, ii.position, ii.object);
-                needPopulate = true;
-
-                if (mCurItem == ii.position) {
-                    // Keep the current item in the valid range
-                    newCurrItem = Math.max(0, Math.min(mCurItem, adapterCount - 1));
-                    needPopulate = true;
-                }
-                continue;
-            }
-
-            if (ii.position != newPos) {
-                if (ii.position == mCurItem) {
-                    // Our current item changed position. Follow it.
-                    newCurrItem = newPos;
-                }
-
-                ii.position = newPos;
-                needPopulate = true;
-            }
-        }
-
-        if (isUpdating) {
-            mAdapter.finishUpdate(this);
-        }
-
-        Collections.sort(mItems, COMPARATOR);
-
-        if (needPopulate) {
-            // Reset our known page widths; populate will recompute them.
-            final int childCount = getChildCount();
-            for (int i = 0; i < childCount; i++) {
-                final View child = getChildAt(i);
-                final LayoutParams lp = (LayoutParams) child.getLayoutParams();
-                if (!lp.isDecor) {
-                    lp.heightFactor = 0.f;
-                }
-            }
-
-            setCurrentItemInternal(newCurrItem, false, true);
-            requestLayout();
-        }
-    }
-
-    void populate() {
-        populate(mCurItem);
-    }
-
-    void populate(int newCurrentItem) {
-        ItemInfo oldCurInfo = null;
-        int focusDirection = View.FOCUS_FORWARD;
-        if (mCurItem != newCurrentItem) {
-            focusDirection = mCurItem < newCurrentItem ? View.FOCUS_DOWN : View.FOCUS_UP;
-            oldCurInfo = infoForPosition(mCurItem);
-            mCurItem = newCurrentItem;
-        }
-
-        if (mAdapter == null) {
-            sortChildDrawingOrder();
-            return;
-        }
-
-        // Bail now if we are waiting to populate.  This is to hold off
-        // on creating views from the time the user releases their finger to
-        // fling to a new position until we have finished the scroll to
-        // that position, avoiding glitches from happening at that point.
-        if (mPopulatePending) {
-            if (DEBUG) Log.i(TAG, "populate is pending, skipping for now...");
-            sortChildDrawingOrder();
-            return;
-        }
-
-        // Also, don't populate until we are attached to a window.  This is to
-        // avoid trying to populate before we have restored our view hierarchy
-        // state and conflicting with what is restored.
-        if (getWindowToken() == null) {
-            return;
-        }
-
-        mAdapter.startUpdate(this);
-
-        final int pageLimit = mOffscreenPageLimit;
-        final int startPos = Math.max(0, mCurItem - pageLimit);
-        final int N = mAdapter.getCount();
-        final int endPos = Math.min(N-1, mCurItem + pageLimit);
-
-        if (N != mExpectedAdapterCount) {
-            String resName;
-            try {
-                resName = getResources().getResourceName(getId());
-            } catch (Resources.NotFoundException e) {
-                resName = Integer.toHexString(getId());
-            }
-            throw new IllegalStateException("The application's PagerAdapter changed the adapter's" +
-                    " contents without calling PagerAdapter#notifyDataSetChanged!" +
-                    " Expected adapter item count: " + mExpectedAdapterCount + ", found: " + N +
-                    " Pager id: " + resName +
-                    " Pager class: " + getClass() +
-                    " Problematic adapter: " + mAdapter.getClass());
-        }
-
-        // Locate the currently focused item or add it if needed.
-        int curIndex;
-        ItemInfo curItem = null;
-        for (curIndex = 0; curIndex < mItems.size(); curIndex++) {
-            final ItemInfo ii = mItems.get(curIndex);
-            if (ii.position >= mCurItem) {
-                if (ii.position == mCurItem) curItem = ii;
-                break;
-            }
-        }
-
-        if (curItem == null && N > 0) {
-            curItem = addNewItem(mCurItem, curIndex);
-        }
-
-        // Fill 3x the available width or up to the number of offscreen
-        // pages requested to either side, whichever is larger.
-        // If we have no current item we have no work to do.
-        if (curItem != null) {
-            float extraHeightTop = 0.f;
-            int itemIndex = curIndex - 1;
-            ItemInfo ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
-            final int clientHeight = getClientHeight();
-            final float topHeightNeeded = clientHeight <= 0 ? 0 :
-                    2.f - curItem.heightFactor + (float) getPaddingLeft() / (float) clientHeight;
-            for (int pos = mCurItem - 1; pos >= 0; pos--) {
-                if (extraHeightTop >= topHeightNeeded && pos < startPos) {
-                    if (ii == null) {
-                        break;
-                    }
-                    if (pos == ii.position && !ii.scrolling) {
-                        mItems.remove(itemIndex);
-                        mAdapter.destroyItem(this, pos, ii.object);
-                        if (DEBUG) {
-                            Log.i(TAG, "populate() - destroyItem() with pos: " + pos +
-                                    " view: " + ((View) ii.object));
-                        }
-                        itemIndex--;
-                        curIndex--;
-                        ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
-                    }
-                } else if (ii != null && pos == ii.position) {
-                    extraHeightTop += ii.heightFactor;
-                    itemIndex--;
-                    ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
-                } else {
-                    ii = addNewItem(pos, itemIndex + 1);
-                    extraHeightTop += ii.heightFactor;
-                    curIndex++;
-                    ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
-                }
-            }
-
-            float extraHeightBottom = curItem.heightFactor;
-            itemIndex = curIndex + 1;
-            if (extraHeightBottom < 2.f) {
-                ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
-                final float bottomHeightNeeded = clientHeight <= 0 ? 0 :
-                        (float) getPaddingRight() / (float) clientHeight + 2.f;
-                for (int pos = mCurItem + 1; pos < N; pos++) {
-                    if (extraHeightBottom >= bottomHeightNeeded && pos > endPos) {
-                        if (ii == null) {
-                            break;
-                        }
-                        if (pos == ii.position && !ii.scrolling) {
-                            mItems.remove(itemIndex);
-                            mAdapter.destroyItem(this, pos, ii.object);
-                            if (DEBUG) {
-                                Log.i(TAG, "populate() - destroyItem() with pos: " + pos +
-                                        " view: " + ((View) ii.object));
-                            }
-                            ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
-                        }
-                    } else if (ii != null && pos == ii.position) {
-                        extraHeightBottom += ii.heightFactor;
-                        itemIndex++;
-                        ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
-                    } else {
-                        ii = addNewItem(pos, itemIndex);
-                        itemIndex++;
-                        extraHeightBottom += ii.heightFactor;
-                        ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
-                    }
-                }
-            }
-
-            calculatePageOffsets(curItem, curIndex, oldCurInfo);
-        }
-
-        if (DEBUG) {
-            Log.i(TAG, "Current page list:");
-            for (int i=0; i<mItems.size(); i++) {
-                Log.i(TAG, "#" + i + ": page " + mItems.get(i).position);
-            }
-        }
-
-        mAdapter.setPrimaryItem(this, mCurItem, curItem != null ? curItem.object : null);
-
-        mAdapter.finishUpdate(this);
-
-        // Check width measurement of current pages and drawing sort order.
-        // Update LayoutParams as needed.
-        final int childCount = getChildCount();
-        for (int i = 0; i < childCount; i++) {
-            final View child = getChildAt(i);
-            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
-            lp.childIndex = i;
-            if (!lp.isDecor && lp.heightFactor == 0.f) {
-                // 0 means requery the adapter for this, it doesn't have a valid width.
-                final ItemInfo ii = infoForChild(child);
-                if (ii != null) {
-                    lp.heightFactor = ii.heightFactor;
-                    lp.position = ii.position;
-                }
-            }
-        }
-        sortChildDrawingOrder();
-
-        if (hasFocus()) {
-            View currentFocused = findFocus();
-            ItemInfo ii = currentFocused != null ? infoForAnyChild(currentFocused) : null;
-            if (ii == null || ii.position != mCurItem) {
-                for (int i=0; i<getChildCount(); i++) {
-                    View child = getChildAt(i);
-                    ii = infoForChild(child);
-                    if (ii != null && ii.position == mCurItem && child.requestFocus(focusDirection)) {
-                        break;
-                    }
-                }
-            }
-        }
-    }
-
-    private void sortChildDrawingOrder() {
-        if (mDrawingOrder != DRAW_ORDER_DEFAULT) {
-            if (mDrawingOrderedChildren == null) {
-                mDrawingOrderedChildren = new ArrayList<View>();
-            } else {
-                mDrawingOrderedChildren.clear();
-            }
-            final int childCount = getChildCount();
-            for (int i = 0; i < childCount; i++) {
-                final View child = getChildAt(i);
-                mDrawingOrderedChildren.add(child);
-            }
-            Collections.sort(mDrawingOrderedChildren, sPositionComparator);
-        }
-    }
-
-    private void calculatePageOffsets(ItemInfo curItem, int curIndex, ItemInfo oldCurInfo) {
-        final int N = mAdapter.getCount();
-        final int height = getClientHeight();
-        final float marginOffset = height > 0 ? (float) mPageMargin / height : 0;
-        // Fix up offsets for later layout.
-        if (oldCurInfo != null) {
-            final int oldCurPosition = oldCurInfo.position;
-            // Base offsets off of oldCurInfo.
-            if (oldCurPosition < curItem.position) {
-                int itemIndex = 0;
-                ItemInfo ii;
-                float offset = oldCurInfo.offset + oldCurInfo.heightFactor + marginOffset;
-                for (int pos = oldCurPosition + 1;
-                     pos <= curItem.position && itemIndex < mItems.size(); pos++) {
-                    ii = mItems.get(itemIndex);
-                    while (pos > ii.position && itemIndex < mItems.size() - 1) {
-                        itemIndex++;
-                        ii = mItems.get(itemIndex);
-                    }
-                    while (pos < ii.position) {
-                        // We don't have an item populated for this,
-                        // ask the adapter for an offset.
-                        offset += mAdapter.getPageWidth(pos) + marginOffset;
-                        pos++;
-                    }
-                    ii.offset = offset;
-                    offset += ii.heightFactor + marginOffset;
-                }
-            } else if (oldCurPosition > curItem.position) {
-                int itemIndex = mItems.size() - 1;
-                ItemInfo ii;
-                float offset = oldCurInfo.offset;
-                for (int pos = oldCurPosition - 1;
-                     pos >= curItem.position && itemIndex >= 0; pos--) {
-                    ii = mItems.get(itemIndex);
-                    while (pos < ii.position && itemIndex > 0) {
-                        itemIndex--;
-                        ii = mItems.get(itemIndex);
-                    }
-                    while (pos > ii.position) {
-                        // We don't have an item populated for this,
-                        // ask the adapter for an offset.
-                        offset -= mAdapter.getPageWidth(pos) + marginOffset;
-                        pos--;
-                    }
-                    offset -= ii.heightFactor + marginOffset;
-                    ii.offset = offset;
-                }
-            }
-        }
-
-        // Base all offsets off of curItem.
-        final int itemCount = mItems.size();
-        float offset = curItem.offset;
-        int pos = curItem.position - 1;
-        mFirstOffset = curItem.position == 0 ? curItem.offset : -Float.MAX_VALUE;
-        mLastOffset = curItem.position == N - 1 ?
-                curItem.offset + curItem.heightFactor - 1 : Float.MAX_VALUE;
-        // Previous pages
-        for (int i = curIndex - 1; i >= 0; i--, pos--) {
-            final ItemInfo ii = mItems.get(i);
-            while (pos > ii.position) {
-                offset -= mAdapter.getPageWidth(pos--) + marginOffset;
-            }
-            offset -= ii.heightFactor + marginOffset;
-            ii.offset = offset;
-            if (ii.position == 0) mFirstOffset = offset;
-        }
-        offset = curItem.offset + curItem.heightFactor + marginOffset;
-        pos = curItem.position + 1;
-        // Next pages
-        for (int i = curIndex + 1; i < itemCount; i++, pos++) {
-            final ItemInfo ii = mItems.get(i);
-            while (pos < ii.position) {
-                offset += mAdapter.getPageWidth(pos++) + marginOffset;
-            }
-            if (ii.position == N - 1) {
-                mLastOffset = offset + ii.heightFactor - 1;
-            }
-            ii.offset = offset;
-            offset += ii.heightFactor + marginOffset;
-        }
-
-        mNeedCalculatePageOffsets = false;
-    }
-
-    /**
-     * This is the persistent state that is saved by ViewPager.  Only needed
-     * if you are creating a sublass of ViewPager that must save its own
-     * state, in which case it should implement a subclass of this which
-     * contains that state.
-     */
-    public static class SavedState extends BaseSavedState {
-        private int position;
-        private Parcelable adapterState;
-        private ClassLoader loader;
-
-        public static final Parcelable.Creator<SavedState> CREATOR
-                = ParcelableCompat.newCreator(new ParcelableCompatCreatorCallbacks<SavedState>() {
-            @Override
-            public SavedState createFromParcel(Parcel in, ClassLoader loader) {
-                return new SavedState(in, loader);
-            }
-            @Override
-            public SavedState[] newArray(int size) {
-                return new SavedState[size];
-            }
-        });
-
-        public SavedState(Parcelable superState) {
-            super(superState);
-        }
-
-        SavedState(Parcel in, ClassLoader loader) {
-            super(in);
-            if (loader == null) {
-                loader = getClass().getClassLoader();
-            }
-            position = in.readInt();
-            adapterState = in.readParcelable(loader);
-            this.loader = loader;
-        }
-
-        @Override
-        public void writeToParcel(Parcel out, int flags) {
-            super.writeToParcel(out, flags);
-            out.writeInt(position);
-            out.writeParcelable(adapterState, flags);
-        }
-
-        @Override
-        public String toString() {
-            return "FragmentPager.SavedState{"
-                    + Integer.toHexString(System.identityHashCode(this))
-                    + " position=" + position + "}";
-        }
-
-    }
-
-    @Override
-    public Parcelable onSaveInstanceState() {
-        Parcelable superState = super.onSaveInstanceState();
-        SavedState ss = new SavedState(superState);
-        ss.position = mCurItem;
-        if (mAdapter != null) {
-            ss.adapterState = mAdapter.saveState();
-        }
-        return ss;
-    }
-
-    @Override
-    public void onRestoreInstanceState(Parcelable state) {
-        if (!(state instanceof SavedState)) {
-            super.onRestoreInstanceState(state);
-            return;
-        }
-
-        SavedState ss = (SavedState)state;
-        super.onRestoreInstanceState(ss.getSuperState());
-
-        if (mAdapter != null) {
-            mAdapter.restoreState(ss.adapterState, ss.loader);
-            setCurrentItemInternal(ss.position, false, true);
-        } else {
-            mRestoredCurItem = ss.position;
-            mRestoredAdapterState = ss.adapterState;
-            mRestoredClassLoader = ss.loader;
-        }
-    }
-
-    @Override
-    public void addView(View child, int index, ViewGroup.LayoutParams params) {
-        if (!checkLayoutParams(params)) {
-            params = generateLayoutParams(params);
-        }
-        final LayoutParams lp = (LayoutParams) params;
-        lp.isDecor |= child instanceof Decor;
-        if (mInLayout) {
-            if (lp != null && lp.isDecor) {
-                throw new IllegalStateException("Cannot add pager decor view during layout");
-            }
-            lp.needsMeasure = true;
-            addViewInLayout(child, index, params);
-        } else {
-            super.addView(child, index, params);
-        }
-
-        if (USE_CACHE) {
-            if (child.getVisibility() != GONE) {
-                child.setDrawingCacheEnabled(mScrollingCacheEnabled);
-            } else {
-                child.setDrawingCacheEnabled(false);
-            }
-        }
-    }
-
-    @Override
-    public void removeView(View view) {
-        if (mInLayout) {
-            removeViewInLayout(view);
-        } else {
-            super.removeView(view);
-        }
-    }
-
-    ItemInfo infoForChild(View child) {
-        for (int i=0; i<mItems.size(); i++) {
-            ItemInfo ii = mItems.get(i);
-            if (mAdapter.isViewFromObject(child, ii.object)) {
-                return ii;
-            }
-        }
-        return null;
-    }
-
-    ItemInfo infoForAnyChild(View child) {
-        ViewParent parent;
-        while ((parent=child.getParent()) != this) {
-            if (parent == null || !(parent instanceof View)) {
-                return null;
-            }
-            child = (View)parent;
-        }
-        return infoForChild(child);
-    }
-
-    ItemInfo infoForPosition(int position) {
-        for (int i = 0; i < mItems.size(); i++) {
-            ItemInfo ii = mItems.get(i);
-            if (ii.position == position) {
-                return ii;
-            }
-        }
-        return null;
-    }
-
-    @Override
-    protected void onAttachedToWindow() {
-        super.onAttachedToWindow();
-        mFirstLayout = true;
-    }
-
-    @Override
-    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
-        // For simple implementation, our internal size is always 0.
-        // We depend on the container to specify the layout size of
-        // our view.  We can't really know what it is since we will be
-        // adding and removing different arbitrary views and do not
-        // want the layout to change as this happens.
-        setMeasuredDimension(getDefaultSize(0, widthMeasureSpec),
-                getDefaultSize(0, heightMeasureSpec));
-
-        final int measuredHeight = getMeasuredHeight();
-        final int maxGutterSize = measuredHeight / 10;
-        mGutterSize = Math.min(maxGutterSize, mDefaultGutterSize);
-
-        // Children are just made to fill our space.
-        int childWidthSize = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
-        int childHeightSize = measuredHeight - getPaddingTop() - getPaddingBottom();
-
-        /*
-         * Make sure all children have been properly measured. Decor views first.
-         * Right now we cheat and make this less complicated by assuming decor
-         * views won't intersect. We will pin to edges based on gravity.
-         */
-        int size = getChildCount();
-        for (int i = 0; i < size; ++i) {
-            final View child = getChildAt(i);
-            if (child.getVisibility() != GONE) {
-                final LayoutParams lp = (LayoutParams) child.getLayoutParams();
-                if (lp != null && lp.isDecor) {
-                    final int hgrav = lp.gravity & Gravity.HORIZONTAL_GRAVITY_MASK;
-                    final int vgrav = lp.gravity & Gravity.VERTICAL_GRAVITY_MASK;
-                    int widthMode = MeasureSpec.AT_MOST;
-                    int heightMode = MeasureSpec.AT_MOST;
-                    boolean consumeVertical = vgrav == Gravity.TOP || vgrav == Gravity.BOTTOM;
-                    boolean consumeHorizontal = hgrav == Gravity.LEFT || hgrav == Gravity.RIGHT;
-
-                    if (consumeVertical) {
-                        widthMode = MeasureSpec.EXACTLY;
-                    } else if (consumeHorizontal) {
-                        heightMode = MeasureSpec.EXACTLY;
-                    }
-
-                    int widthSize = childWidthSize;
-                    int heightSize = childHeightSize;
-                    if (lp.width != LayoutParams.WRAP_CONTENT) {
-                        widthMode = MeasureSpec.EXACTLY;
-                        if (lp.width != LayoutParams.FILL_PARENT) {
-                            widthSize = lp.width;
-                        }
-                    }
-                    if (lp.height != LayoutParams.WRAP_CONTENT) {
-                        heightMode = MeasureSpec.EXACTLY;
-                        if (lp.height != LayoutParams.FILL_PARENT) {
-                            heightSize = lp.height;
-                        }
-                    }
-                    final int widthSpec = MeasureSpec.makeMeasureSpec(widthSize, widthMode);
-                    final int heightSpec = MeasureSpec.makeMeasureSpec(heightSize, heightMode);
-                    child.measure(widthSpec, heightSpec);
-
-                    if (consumeVertical) {
-                        childHeightSize -= child.getMeasuredHeight();
-                    } else if (consumeHorizontal) {
-                        childWidthSize -= child.getMeasuredWidth();
-                    }
-                }
-            }
-        }
-
-        mChildWidthMeasureSpec = MeasureSpec.makeMeasureSpec(childWidthSize, MeasureSpec.EXACTLY);
-        mChildHeightMeasureSpec = MeasureSpec.makeMeasureSpec(childHeightSize, MeasureSpec.EXACTLY);
-
-        // Make sure we have created all fragments that we need to have shown.
-        mInLayout = true;
-        populate();
-        mInLayout = false;
-
-        // Page views next.
-        size = getChildCount();
-        for (int i = 0; i < size; ++i) {
-            final View child = getChildAt(i);
-            if (child.getVisibility() != GONE) {
-                if (DEBUG) Log.v(TAG, "Measuring #" + i + " " + child
-                        + ": " + mChildWidthMeasureSpec);
-
-                final LayoutParams lp = (LayoutParams) child.getLayoutParams();
-                if (lp == null || !lp.isDecor) {
-                    final int heightSpec = MeasureSpec.makeMeasureSpec(
-                            (int) (childHeightSize * lp.heightFactor), MeasureSpec.EXACTLY);
-                    child.measure(mChildWidthMeasureSpec, heightSpec);
-                }
-            }
-        }
-    }
-
-    @Override
-    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
-        super.onSizeChanged(w, h, oldw, oldh);
-
-        // Make sure scroll position is set correctly.
-        if (h != oldh) {
-            recomputeScrollPosition(h, oldh, mPageMargin, mPageMargin);
-        }
-    }
-
-    private void recomputeScrollPosition(int height, int oldHeight, int margin, int oldMargin) {
-        if (oldHeight > 0 && !mItems.isEmpty()) {
-            final int heightWithMargin = height - getPaddingTop() - getPaddingBottom() + margin;
-            final int oldHeightWithMargin = oldHeight - getPaddingTop() - getPaddingBottom()
-                    + oldMargin;
-            final int ypos = getScrollY();
-            final float pageOffset = (float) ypos / oldHeightWithMargin;
-            final int newOffsetPixels = (int) (pageOffset * heightWithMargin);
-
-            scrollTo(getScrollX(), newOffsetPixels);
-            if (!mScroller.isFinished()) {
-                // We now return to your regularly scheduled scroll, already in progress.
-                final int newDuration = mScroller.getDuration() - mScroller.timePassed();
-                ItemInfo targetInfo = infoForPosition(mCurItem);
-                mScroller.startScroll(0, newOffsetPixels,
-                        0, (int) (targetInfo.offset * height), newDuration);
-            }
-        } else {
-            final ItemInfo ii = infoForPosition(mCurItem);
-            final float scrollOffset = ii != null ? Math.min(ii.offset, mLastOffset) : 0;
-            final int scrollPos = (int) (scrollOffset *
-                    (height - getPaddingTop() - getPaddingBottom()));
-            if (scrollPos != getScrollY()) {
-                completeScroll(false);
-                scrollTo(getScrollX(), scrollPos);
-            }
-        }
-    }
-
-    @Override
-    protected void onLayout(boolean changed, int l, int t, int r, int b) {
-        final int count = getChildCount();
-        int width = r - l;
-        int height = b - t;
-        int paddingLeft = getPaddingLeft();
-        int paddingTop = getPaddingTop();
-        int paddingRight = getPaddingRight();
-        int paddingBottom = getPaddingBottom();
-        final int scrollY = getScrollY();
-
-        int decorCount = 0;
-
-        // First pass - decor views. We need to do this in two passes so that
-        // we have the proper offsets for non-decor views later.
-        for (int i = 0; i < count; i++) {
-            final View child = getChildAt(i);
-            if (child.getVisibility() != GONE) {
-                final LayoutParams lp = (LayoutParams) child.getLayoutParams();
-                int childLeft;
-                int childTop;
-                if (lp.isDecor) {
-                    final int hgrav = lp.gravity & Gravity.HORIZONTAL_GRAVITY_MASK;
-                    final int vgrav = lp.gravity & Gravity.VERTICAL_GRAVITY_MASK;
-                    switch (hgrav) {
-                        case Gravity.LEFT:
-                            childLeft = paddingLeft;
-                            paddingLeft += child.getMeasuredWidth();
-                            break;
-                        case Gravity.CENTER_HORIZONTAL:
-                            childLeft = Math.max((width - child.getMeasuredWidth()) / 2,
-                                    paddingLeft);
-                            break;
-                        case Gravity.RIGHT:
-                            childLeft = width - paddingRight - child.getMeasuredWidth();
-                            paddingRight += child.getMeasuredWidth();
-                            break;
-                        default:
-                            childLeft = paddingLeft;
-                            break;
-                    }
-                    switch (vgrav) {
-                        case Gravity.TOP:
-                            childTop = paddingTop;
-                            paddingTop += child.getMeasuredHeight();
-                            break;
-                        case Gravity.CENTER_VERTICAL:
-                            childTop = Math.max((height - child.getMeasuredHeight()) / 2,
-                                    paddingTop);
-                            break;
-                        case Gravity.BOTTOM:
-                            childTop = height - paddingBottom - child.getMeasuredHeight();
-                            paddingBottom += child.getMeasuredHeight();
-                            break;
-                        default:
-                            childTop = paddingTop;
-                            break;
-                    }
-                    childTop += scrollY;
-                    child.layout(childLeft, childTop,
-                            childLeft + child.getMeasuredWidth(),
-                            childTop + child.getMeasuredHeight());
-                    decorCount++;
-                }
-            }
-        }
-
-        final int childHeight = height - paddingTop - paddingBottom;
-        // Page views. Do this once we have the right padding offsets from above.
-        for (int i = 0; i < count; i++) {
-            final View child = getChildAt(i);
-            if (child.getVisibility() != GONE) {
-                final LayoutParams lp = (LayoutParams) child.getLayoutParams();
-                ItemInfo ii;
-                if (!lp.isDecor && (ii = infoForChild(child)) != null) {
-                    int toff = (int) (childHeight * ii.offset);
-                    int childLeft = paddingLeft;
-                    int childTop = paddingTop + toff;
-                    if (lp.needsMeasure) {
-                        // This was added during layout and needs measurement.
-                        // Do it now that we know what we're working with.
-                        lp.needsMeasure = false;
-                        final int widthSpec = MeasureSpec.makeMeasureSpec(
-                                (int) (width - paddingLeft - paddingRight),
-                                MeasureSpec.EXACTLY);
-                        final int heightSpec = MeasureSpec.makeMeasureSpec(
-                                (int) (childHeight * lp.heightFactor),
-                                MeasureSpec.EXACTLY);
-                        child.measure(widthSpec, heightSpec);
-                    }
-                    if (DEBUG) Log.v(TAG, "Positioning #" + i + " " + child + " f=" + ii.object
-                            + ":" + childLeft + "," + childTop + " " + child.getMeasuredWidth()
-                            + "x" + child.getMeasuredHeight());
-                    child.layout(childLeft, childTop,
-                            childLeft + child.getMeasuredWidth(),
-                            childTop + child.getMeasuredHeight());
-                }
-            }
-        }
-        mLeftPageBounds = paddingLeft;
-        mRightPageBounds = width - paddingRight;
-        mDecorChildCount = decorCount;
-
-        if (mFirstLayout) {
-            scrollToItem(mCurItem, false, 0, false);
-        }
-        mFirstLayout = false;
-    }
-
-    @Override
-    public void computeScroll() {
-        if (!mScroller.isFinished() && mScroller.computeScrollOffset()) {
-            int oldX = getScrollX();
-            int oldY = getScrollY();
-            int x = mScroller.getCurrX();
-            int y = mScroller.getCurrY();
-
-            if (oldX != x || oldY != y) {
-                scrollTo(x, y);
-                if (!pageScrolled(y)) {
-                    mScroller.abortAnimation();
-                    scrollTo(x, 0);
-                }
-            }
-
-            // Keep on drawing until the animation has finished.
-            ViewCompat.postInvalidateOnAnimation(this);
-            return;
-        }
-
-        // Done with scroll, clean up state.
-        completeScroll(true);
-    }
-
-    private boolean pageScrolled(int ypos) {
-        if (mItems.isEmpty()) {
-            mCalledSuper = false;
-            onPageScrolled(0, 0, 0);
-            if (!mCalledSuper) {
-                throw new IllegalStateException(
-                        "onPageScrolled did not call superclass implementation");
-            }
-            return false;
-        }
-        final ItemInfo ii = infoForCurrentScrollPosition();
-        final int height = getClientHeight();
-        final int heightWithMargin = height + mPageMargin;
-        final float marginOffset = (float) mPageMargin / height;
-        final int currentPage = ii.position;
-        final float pageOffset = (((float) ypos / height) - ii.offset) /
-                (ii.heightFactor + marginOffset);
-        final int offsetPixels = (int) (pageOffset * heightWithMargin);
-
-        mCalledSuper = false;
-        onPageScrolled(currentPage, pageOffset, offsetPixels);
-        if (!mCalledSuper) {
-            throw new IllegalStateException(
-                    "onPageScrolled did not call superclass implementation");
-        }
-        return true;
-    }
-
-    /**
-     * This method will be invoked when the current page is scrolled, either as part
-     * of a programmatically initiated smooth scroll or a user initiated touch scroll.
-     * If you override this method you must call through to the superclass implementation
-     * (e.g. super.onPageScrolled(position, offset, offsetPixels)) before onPageScrolled
-     * returns.
-     *
-     * @param position Position index of the first page currently being displayed.
-     *                 Page position+1 will be visible if positionOffset is nonzero.
-     * @param offset Value from [0, 1) indicating the offset from the page at position.
-     * @param offsetPixels Value in pixels indicating the offset from position.
-     */
-    @CallSuper
-    protected void onPageScrolled(int position, float offset, int offsetPixels) {
-        // Offset any decor views if needed - keep them on-screen at all times.
-        if (mDecorChildCount > 0) {
-            final int scrollY = getScrollY();
-            int paddingTop = getPaddingTop();
-            int paddingBottom = getPaddingBottom();
-            final int height = getHeight();
-            final int childCount = getChildCount();
-            for (int i = 0; i < childCount; i++) {
-                final View child = getChildAt(i);
-                final LayoutParams lp = (LayoutParams) child.getLayoutParams();
-                if (!lp.isDecor) continue;
-
-                final int vgrav = lp.gravity & Gravity.VERTICAL_GRAVITY_MASK;
-                int childTop;
-                switch (vgrav) {
-                    case Gravity.TOP:
-                        childTop = paddingTop;
-                        paddingTop += child.getHeight();
-                        break;
-                    case Gravity.CENTER_VERTICAL:
-                        childTop = Math.max((height - child.getMeasuredHeight()) / 2,
-                                paddingTop);
-                        break;
-                    case Gravity.BOTTOM:
-                        childTop = height - paddingBottom - child.getMeasuredHeight();
-                        paddingBottom += child.getMeasuredHeight();
-                        break;
-                    default:
-                        childTop = paddingTop;
-                        break;
-                }
-                childTop += scrollY;
-
-                final int childOffset = childTop - child.getTop();
-                if (childOffset != 0) {
-                    child.offsetTopAndBottom(childOffset);
-                }
-            }
-        }
-
-        dispatchOnPageScrolled(position, offset, offsetPixels);
-
-        if (mPageTransformer != null) {
-            final int scrollY = getScrollY();
-            final int childCount = getChildCount();
-            for (int i = 0; i < childCount; i++) {
-                final View child = getChildAt(i);
-                final LayoutParams lp = (LayoutParams) child.getLayoutParams();
-
-                if (lp.isDecor) continue;
-
-                final float transformPos = (float) (child.getTop() - scrollY) / getClientHeight();
-                mPageTransformer.transformPage(child, transformPos);
-            }
-        }
-
-        mCalledSuper = true;
-    }
-
-    private void dispatchOnPageScrolled(int position, float offset, int offsetPixels) {
-        if (mOnPageChangeListener != null) {
-            mOnPageChangeListener.onPageScrolled(position, offset, offsetPixels);
-        }
-        if (mOnPageChangeListeners != null) {
-            for (int i = 0, z = mOnPageChangeListeners.size(); i < z; i++) {
-                OnPageChangeListener listener = mOnPageChangeListeners.get(i);
-                if (listener != null) {
-                    listener.onPageScrolled(position, offset, offsetPixels);
-                }
-            }
-        }
-        if (mInternalPageChangeListener != null) {
-            mInternalPageChangeListener.onPageScrolled(position, offset, offsetPixels);
-        }
-    }
-
-    private void dispatchOnPageSelected(int position) {
-        if (mOnPageChangeListener != null) {
-            mOnPageChangeListener.onPageSelected(position);
-        }
-        if (mOnPageChangeListeners != null) {
-            for (int i = 0, z = mOnPageChangeListeners.size(); i < z; i++) {
-                OnPageChangeListener listener = mOnPageChangeListeners.get(i);
-                if (listener != null) {
-                    listener.onPageSelected(position);
-                }
-            }
-        }
-        if (mInternalPageChangeListener != null) {
-            mInternalPageChangeListener.onPageSelected(position);
-        }
-    }
-
-    private void dispatchOnScrollStateChanged(int state) {
-        if (mOnPageChangeListener != null) {
-            mOnPageChangeListener.onPageScrollStateChanged(state);
-        }
-        if (mOnPageChangeListeners != null) {
-            for (int i = 0, z = mOnPageChangeListeners.size(); i < z; i++) {
-                OnPageChangeListener listener = mOnPageChangeListeners.get(i);
-                if (listener != null) {
-                    listener.onPageScrollStateChanged(state);
-                }
-            }
-        }
-        if (mInternalPageChangeListener != null) {
-            mInternalPageChangeListener.onPageScrollStateChanged(state);
-        }
-    }
-
-    private void completeScroll(boolean postEvents) {
-        boolean needPopulate = mScrollState == SCROLL_STATE_SETTLING;
-        if (needPopulate) {
-            // Done with scroll, no longer want to cache view drawing.
-            setScrollingCacheEnabled(false);
-            mScroller.abortAnimation();
-            int oldX = getScrollX();
-            int oldY = getScrollY();
-            int x = mScroller.getCurrX();
-            int y = mScroller.getCurrY();
-            if (oldX != x || oldY != y) {
-                scrollTo(x, y);
-                if (y != oldY) {
-                    pageScrolled(y);
-                }
-            }
-        }
-        mPopulatePending = false;
-        for (int i=0; i<mItems.size(); i++) {
-            ItemInfo ii = mItems.get(i);
-            if (ii.scrolling) {
-                needPopulate = true;
-                ii.scrolling = false;
-            }
-        }
-        if (needPopulate) {
-            if (postEvents) {
-                ViewCompat.postOnAnimation(this, mEndScrollRunnable);
-            } else {
-                mEndScrollRunnable.run();
-            }
-        }
-    }
-
-    private boolean isGutterDrag(float y, float dy) {
-        return (y < mGutterSize && dy > 0) || (y > getHeight() - mGutterSize && dy < 0);
-    }
-
-    private void enableLayers(boolean enable) {
-        final int childCount = getChildCount();
-        for (int i = 0; i < childCount; i++) {
-            final int layerType = enable ?
-                    ViewCompat.LAYER_TYPE_HARDWARE : ViewCompat.LAYER_TYPE_NONE;
-            ViewCompat.setLayerType(getChildAt(i), layerType, null);
-        }
-    }
-
-    @Override
-    public boolean onInterceptTouchEvent(MotionEvent ev) {
-        /*
-         * This method JUST determines whether we want to intercept the motion.
-         * If we return true, onMotionEvent will be called and we do the actual
-         * scrolling there.
-         */
-
-        final int action = ev.getAction() & MotionEventCompat.ACTION_MASK;
-
-        // Always take care of the touch gesture being complete.
-        if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
-            // Release the drag.
-            if (DEBUG) Log.v(TAG, "Intercept done!");
-            resetTouch();
-            return false;
-        }
-
-        // Nothing more to do here if we have decided whether or not we
-        // are dragging.
-        if (action != MotionEvent.ACTION_DOWN) {
-            if (mIsBeingDragged) {
-                if (DEBUG) Log.v(TAG, "Intercept returning true!");
-                return true;
-            }
-            if (mIsUnableToDrag) {
-                if (DEBUG) Log.v(TAG, "Intercept returning false!");
-                return false;
-            }
-        }
-
-        switch (action) {
-            case MotionEvent.ACTION_MOVE: {
-                /*
-                 * mIsBeingDragged == false, otherwise the shortcut would have caught it. Check
-                 * whether the user has moved far enough from his original down touch.
-                 */
-
-                /*
-                * Locally do absolute value. mLastMotionY is set to the y value
-                * of the down event.
-                */
-                final int activePointerId = mActivePointerId;
-                if (activePointerId == INVALID_POINTER) {
-                    // If we don't have a valid id, the touch down wasn't on content.
-                    break;
-                }
-
-                final int pointerIndex = MotionEventCompat.findPointerIndex(ev, activePointerId);
-                final float y = MotionEventCompat.getY(ev, pointerIndex);
-                final float dy = y - mLastMotionY;
-                final float yDiff = Math.abs(dy);
-                final float x = MotionEventCompat.getX(ev, pointerIndex);
-                final float xDiff = Math.abs(x - mInitialMotionX);
-                if (DEBUG) Log.v(TAG, "Moved x to " + x + "," + y + " diff=" + xDiff + "," + yDiff);
-
-                if (dy != 0 && !isGutterDrag(mLastMotionY, dy) &&
-                        canScroll(this, false, (int) dy, (int) x, (int) y)) {
-                    // Nested view has scrollable area under this point. Let it be handled there.
-                    mLastMotionX = x;
-                    mLastMotionY = y;
-                    mIsUnableToDrag = true;
-                    return false;
-                }
-                if (yDiff > mTouchSlop && yDiff * 0.5f > xDiff) {
-                    if (DEBUG) Log.v(TAG, "Starting drag!");
-                    mIsBeingDragged = true;
-                    requestParentDisallowInterceptTouchEvent(true);
-                    setScrollState(SCROLL_STATE_DRAGGING);
-                    mLastMotionY = dy > 0 ? mInitialMotionY + mTouchSlop :
-                            mInitialMotionY - mTouchSlop;
-                    mLastMotionX = x;
-                    setScrollingCacheEnabled(true);
-                } else if (xDiff > mTouchSlop) {
-                    // The finger has moved enough in the vertical
-                    // direction to be counted as a drag...  abort
-                    // any attempt to drag horizontally, to work correctly
-                    // with children that have scrolling containers.
-                    if (DEBUG) Log.v(TAG, "Starting unable to drag!");
-                    mIsUnableToDrag = true;
-                }
-                // Scroll to follow the motion event
-                if (mIsBeingDragged && performDrag(y)) {
-                    ViewCompat.postInvalidateOnAnimation(this);
-                }
-                break;
-            }
-
-            case MotionEvent.ACTION_DOWN: {
-                /*
-                 * Remember location of down touch.
-                 * ACTION_DOWN always refers to pointer index 0.
-                 */
-                mLastMotionX = mInitialMotionX = ev.getX();
-                mLastMotionY = mInitialMotionY = ev.getY();
-                mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
-                mIsUnableToDrag = false;
-
-                mScroller.computeScrollOffset();
-                if (mScrollState == SCROLL_STATE_SETTLING &&
-                        Math.abs(mScroller.getFinalY() - mScroller.getCurrY()) > mCloseEnough) {
-                    // Let the user 'catch' the pager as it animates.
-                    mScroller.abortAnimation();
-                    mPopulatePending = false;
-                    populate();
-                    mIsBeingDragged = true;
-                    requestParentDisallowInterceptTouchEvent(true);
-                    setScrollState(SCROLL_STATE_DRAGGING);
-                } else {
-                    completeScroll(false);
-                    mIsBeingDragged = false;
-                }
-
-                if (DEBUG) Log.v(TAG, "Down at " + mLastMotionX + "," + mLastMotionY
-                        + " mIsBeingDragged=" + mIsBeingDragged
-                        + "mIsUnableToDrag=" + mIsUnableToDrag);
-                break;
-            }
-
-            case MotionEventCompat.ACTION_POINTER_UP:
-                onSecondaryPointerUp(ev);
-                break;
-            default:
-                break;
-        }
-
-        if (mVelocityTracker == null) {
-            mVelocityTracker = VelocityTracker.obtain();
-        }
-        mVelocityTracker.addMovement(ev);
-
-        /*
-         * The only time we want to intercept motion events is if we are in the
-         * drag mode.
-         */
-        return mIsBeingDragged;
-    }
-
-    @Override
-    public boolean onTouchEvent(MotionEvent ev) {
-        if (mFakeDragging) {
-            // A fake drag is in progress already, ignore this real one
-            // but still eat the touch events.
-            // (It is likely that the user is multi-touching the screen.)
-            return true;
-        }
-
-        if (ev.getAction() == MotionEvent.ACTION_DOWN && ev.getEdgeFlags() != 0) {
-            // Don't handle edge touches immediately -- they may actually belong to one of our
-            // descendants.
-            return false;
-        }
-
-        if (mAdapter == null || mAdapter.getCount() == 0) {
-            // Nothing to present or scroll; nothing to touch.
-            return false;
-        }
-
-        if (mVelocityTracker == null) {
-            mVelocityTracker = VelocityTracker.obtain();
-        }
-        mVelocityTracker.addMovement(ev);
-
-        final int action = ev.getAction();
-        boolean needsInvalidate = false;
-
-        switch (action & MotionEventCompat.ACTION_MASK) {
-            case MotionEvent.ACTION_DOWN: {
-                mScroller.abortAnimation();
-                mPopulatePending = false;
-                populate();
-
-                // Remember where the motion event started
-                mLastMotionX = mInitialMotionX = ev.getX();
-                mLastMotionY = mInitialMotionY = ev.getY();
-                mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
-                break;
-            }
-            case MotionEvent.ACTION_MOVE:
-                if (!mIsBeingDragged) {
-                    final int pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
-                    if (pointerIndex == -1) {
-                        // A child has consumed some touch events and put us into an inconsistent state.
-                        needsInvalidate = resetTouch();
-                        break;
-                    }
-                    final float y = MotionEventCompat.getY(ev, pointerIndex);
-                    final float yDiff = Math.abs(y - mLastMotionY);
-                    final float x = MotionEventCompat.getX(ev, pointerIndex);
-                    final float xDiff = Math.abs(x - mLastMotionX);
-                    if (DEBUG) Log.v(TAG, "Moved x to " + x + "," + y + " diff=" + xDiff + "," + yDiff);
-                    if (yDiff > mTouchSlop && yDiff > xDiff) {
-                        if (DEBUG) Log.v(TAG, "Starting drag!");
-                        mIsBeingDragged = true;
-                        requestParentDisallowInterceptTouchEvent(true);
-                        mLastMotionY = y - mInitialMotionY > 0 ? mInitialMotionY + mTouchSlop :
-                                mInitialMotionY - mTouchSlop;
-                        mLastMotionX = x;
-                        setScrollState(SCROLL_STATE_DRAGGING);
-                        setScrollingCacheEnabled(true);
-
-                        // Disallow Parent Intercept, just in case
-                        ViewParent parent = getParent();
-                        if (parent != null) {
-                            parent.requestDisallowInterceptTouchEvent(true);
-                        }
-                    }
-                }
-                // Not else! Note that mIsBeingDragged can be set above.
-                if (mIsBeingDragged) {
-                    // Scroll to follow the motion event
-                    final int activePointerIndex = MotionEventCompat.findPointerIndex(
-                            ev, mActivePointerId);
-                    final float y = MotionEventCompat.getY(ev, activePointerIndex);
-                    needsInvalidate |= performDrag(y);
-                }
-                break;
-            case MotionEvent.ACTION_UP:
-                if (mIsBeingDragged) {
-                    final VelocityTracker velocityTracker = mVelocityTracker;
-                    velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
-                    int initialVelocity = (int) VelocityTrackerCompat.getYVelocity(
-                            velocityTracker, mActivePointerId);
-                    mPopulatePending = true;
-                    final int height = getClientHeight();
-                    final int scrollY = getScrollY();
-                    final ItemInfo ii = infoForCurrentScrollPosition();
-                    final int currentPage = ii.position;
-                    final float pageOffset = (((float) scrollY / height) - ii.offset) / ii.heightFactor;
-                    final int activePointerIndex =
-                            MotionEventCompat.findPointerIndex(ev, mActivePointerId);
-                    final float y = MotionEventCompat.getY(ev, activePointerIndex);
-                    final int totalDelta = (int) (y - mInitialMotionY);
-                    int nextPage = determineTargetPage(currentPage, pageOffset, initialVelocity,
-                            totalDelta);
-                    setCurrentItemInternal(nextPage, true, true, initialVelocity);
-
-                    needsInvalidate = resetTouch();
-                }
-                break;
-            case MotionEvent.ACTION_CANCEL:
-                if (mIsBeingDragged) {
-                    scrollToItem(mCurItem, true, 0, false);
-                    needsInvalidate = resetTouch();
-                }
-                break;
-            case MotionEventCompat.ACTION_POINTER_DOWN: {
-                final int index = MotionEventCompat.getActionIndex(ev);
-                final float y = MotionEventCompat.getY(ev, index);
-                mLastMotionY = y;
-                mActivePointerId = MotionEventCompat.getPointerId(ev, index);
-                break;
-            }
-            case MotionEventCompat.ACTION_POINTER_UP:
-                onSecondaryPointerUp(ev);
-                mLastMotionY = MotionEventCompat.getY(ev,
-                        MotionEventCompat.findPointerIndex(ev, mActivePointerId));
-                break;
-            default:
-                break;
-        }
-        if (needsInvalidate) {
-            ViewCompat.postInvalidateOnAnimation(this);
-        }
-        return true;
-    }
-
-    private boolean resetTouch() {
-        boolean needsInvalidate;
-        mActivePointerId = INVALID_POINTER;
-        endDrag();
-        needsInvalidate = mTopEdge.onRelease() | mBottomEdge.onRelease();
-        return needsInvalidate;
-    }
-
-    private void requestParentDisallowInterceptTouchEvent(boolean disallowIntercept) {
-        final ViewParent parent = getParent();
-        if (parent != null) {
-            parent.requestDisallowInterceptTouchEvent(disallowIntercept);
-        }
-    }
-
-    private boolean performDrag(float y) {
-        boolean needsInvalidate = false;
-
-        final float deltaY = mLastMotionY - y;
-        mLastMotionY = y;
-
-        float oldScrollY = getScrollY();
-        float scrollY = oldScrollY + deltaY;
-        final int height = getClientHeight();
-
-        float topBound = height * mFirstOffset;
-        float bottomBound = height * mLastOffset;
-        boolean topAbsolute = true;
-        boolean bottomAbsolute = true;
-
-        final ItemInfo firstItem = mItems.get(0);
-        final ItemInfo lastItem = mItems.get(mItems.size() - 1);
-        if (firstItem.position != 0) {
-            topAbsolute = false;
-            topBound = firstItem.offset * height;
-        }
-        if (lastItem.position != mAdapter.getCount() - 1) {
-            bottomAbsolute = false;
-            bottomBound = lastItem.offset * height;
-        }
-
-        if (scrollY < topBound) {
-            if (topAbsolute) {
-                float over = topBound - scrollY;
-                needsInvalidate = mTopEdge.onPull(Math.abs(over) / height);
-            }
-            scrollY = topBound;
-        } else if (scrollY > bottomBound) {
-            if (bottomAbsolute) {
-                float over = scrollY - bottomBound;
-                needsInvalidate = mBottomEdge.onPull(Math.abs(over) / height);
-            }
-            scrollY = bottomBound;
-        }
-        // Don't lose the rounded component
-        mLastMotionY += scrollY - (int) scrollY;
-        scrollTo(getScrollX(), (int) scrollY);
-        pageScrolled((int) scrollY);
-
-        return needsInvalidate;
-    }
-
-    /**
-     * @return Info about the page at the current scroll position.
-     *         This can be synthetic for a missing middle page; the 'object' field can be null.
-     */
-    private ItemInfo infoForCurrentScrollPosition() {
-        final int height = getClientHeight();
-        final float scrollOffset = height > 0 ? (float) getScrollY() / height : 0;
-        final float marginOffset = height > 0 ? (float) mPageMargin / height : 0;
-        int lastPos = -1;
-        float lastOffset = 0.f;
-        float lastHeight = 0.f;
-        boolean first = true;
-
-        ItemInfo lastItem = null;
-        for (int i = 0; i < mItems.size(); i++) {
-            ItemInfo ii = mItems.get(i);
-            float offset;
-            if (!first && ii.position != lastPos + 1) {
-                // Create a synthetic item for a missing page.
-                ii = mTempItem;
-                ii.offset = lastOffset + lastHeight + marginOffset;
-                ii.position = lastPos + 1;
-                ii.heightFactor = mAdapter.getPageWidth(ii.position);
-                i--;
-            }
-            offset = ii.offset;
-
-            final float topBound = offset;
-            final float bottomBound = offset + ii.heightFactor + marginOffset;
-            if (first || scrollOffset >= topBound) {
-                if (scrollOffset < bottomBound || i == mItems.size() - 1) {
-                    return ii;
-                }
-            } else {
-                return lastItem;
-            }
-            first = false;
-            lastPos = ii.position;
-            lastOffset = offset;
-            lastHeight = ii.heightFactor;
-            lastItem = ii;
-        }
-
-        return lastItem;
-    }
-
-    private int determineTargetPage(int currentPage, float pageOffset, int velocity, int deltaY) {
-        int targetPage;
-        if (Math.abs(deltaY) > mFlingDistance && Math.abs(velocity) > mMinimumVelocity) {
-            targetPage = velocity > 0 ? currentPage : currentPage + 1;
-        } else {
-            final float truncator = currentPage >= mCurItem ? 0.4f : 0.6f;
-            targetPage = (int) (currentPage + pageOffset + truncator);
-        }
-
-        if (!mItems.isEmpty()) {
-            final ItemInfo firstItem = mItems.get(0);
-            final ItemInfo lastItem = mItems.get(mItems.size() - 1);
-
-            // Only let the user target pages we have items for
-            targetPage = Math.max(firstItem.position, Math.min(targetPage, lastItem.position));
-        }
-
-        return targetPage;
-    }
-
-    @Override
-    public void draw(Canvas canvas) {
-        super.draw(canvas);
-        boolean needsInvalidate = false;
-
-        final int overScrollMode = ViewCompat.getOverScrollMode(this);
-        if (overScrollMode == ViewCompat.OVER_SCROLL_ALWAYS ||
-                (overScrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS &&
-                        mAdapter != null && mAdapter.getCount() > 1)) {
-            if (!mTopEdge.isFinished()) {
-                final int restoreCount = canvas.save();
-                final int height = getHeight();
-                final int width = getWidth() - getPaddingLeft() - getPaddingRight();
-
-                canvas.translate(getPaddingLeft(), mFirstOffset * height);
-                mTopEdge.setSize(width, height);
-                needsInvalidate |= mTopEdge.draw(canvas);
-                canvas.restoreToCount(restoreCount);
-            }
-            if (!mBottomEdge.isFinished()) {
-                final int restoreCount = canvas.save();
-                final int height = getHeight();
-                final int width = getWidth() - getPaddingLeft() - getPaddingRight();
-
-                canvas.rotate(180);
-                canvas.translate(-width - getPaddingLeft(), -(mLastOffset + 1) * height);
-                mBottomEdge.setSize(width, height);
-                needsInvalidate |= mBottomEdge.draw(canvas);
-                canvas.restoreToCount(restoreCount);
-            }
-        } else {
-            mTopEdge.finish();
-            mBottomEdge.finish();
-        }
-
-        if (needsInvalidate) {
-            // Keep animating
-            ViewCompat.postInvalidateOnAnimation(this);
-        }
-    }
-
-    @Override
-    protected void onDraw(Canvas canvas) {
-        super.onDraw(canvas);
-
-        // Draw the margin drawable between pages if needed.
-        if (mPageMargin > 0 && mMarginDrawable != null && !mItems.isEmpty() && mAdapter != null) {
-            final int scrollY = getScrollY();
-            final int height = getHeight();
-
-            final float marginOffset = (float) mPageMargin / height;
-            int itemIndex = 0;
-            ItemInfo ii = mItems.get(0);
-            float offset = ii.offset;
-            final int itemCount = mItems.size();
-            final int firstPos = ii.position;
-            final int lastPos = mItems.get(itemCount - 1).position;
-            for (int pos = firstPos; pos < lastPos; pos++) {
-                while (pos > ii.position && itemIndex < itemCount) {
-                    ii = mItems.get(++itemIndex);
-                }
-
-                float drawAt;
-                if (pos == ii.position) {
-                    drawAt = (ii.offset + ii.heightFactor) * height;
-                    offset = ii.offset + ii.heightFactor + marginOffset;
-                } else {
-                    float heightFactor = mAdapter.getPageWidth(pos);
-                    drawAt = (offset + heightFactor) * height;
-                    offset += heightFactor + marginOffset;
-                }
-
-                if (drawAt + mPageMargin > scrollY) {
-                    mMarginDrawable.setBounds(mLeftPageBounds, (int) drawAt,
-                            mRightPageBounds, (int) (drawAt + mPageMargin + 0.5f));
-                    mMarginDrawable.draw(canvas);
-                }
-
-                if (drawAt > scrollY + height) {
-                    break; // No more visible, no sense in continuing
-                }
-            }
-        }
-    }
-
-    /**
-     * Start a fake drag of the pager.
-     *
-     * <p>A fake drag can be useful if you want to synchronize the motion of the ViewPager
-     * with the touch scrolling of another view, while still letting the ViewPager
-     * control the snapping motion and fling behavior. (e.g. parallax-scrolling tabs.)
-     * Call {@link #fakeDragBy(float)} to simulate the actual drag motion. Call
-     * {@link #endFakeDrag()} to complete the fake drag and fling as necessary.
-     *
-     * <p>During a fake drag the ViewPager will ignore all touch events. If a real drag
-     * is already in progress, this method will return false.
-     *
-     * @return true if the fake drag began successfully, false if it could not be started.
-     *
-     * @see #fakeDragBy(float)
-     * @see #endFakeDrag()
-     */
-    public boolean beginFakeDrag() {
-        if (mIsBeingDragged) {
-            return false;
-        }
-        mFakeDragging = true;
-        setScrollState(SCROLL_STATE_DRAGGING);
-        mInitialMotionY = mLastMotionY = 0;
-        if (mVelocityTracker == null) {
-            mVelocityTracker = VelocityTracker.obtain();
-        } else {
-            mVelocityTracker.clear();
-        }
-        final long time = SystemClock.uptimeMillis();
-        final MotionEvent ev = MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN, 0, 0, 0);
-        mVelocityTracker.addMovement(ev);
-        ev.recycle();
-        mFakeDragBeginTime = time;
-        return true;
-    }
-
-    /**
-     * End a fake drag of the pager.
-     *
-     * @see #beginFakeDrag()
-     * @see #fakeDragBy(float)
-     */
-    public void endFakeDrag() {
-        if (!mFakeDragging) {
-            throw new IllegalStateException("No fake drag in progress. Call beginFakeDrag first.");
-        }
-
-        final VelocityTracker velocityTracker = mVelocityTracker;
-        velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
-        int initialVelocity = (int) VelocityTrackerCompat.getYVelocity(
-                velocityTracker, mActivePointerId);
-        mPopulatePending = true;
-        final int height = getClientHeight();
-        final int scrollY = getScrollY();
-        final ItemInfo ii = infoForCurrentScrollPosition();
-        final int currentPage = ii.position;
-        final float pageOffset = (((float) scrollY / height) - ii.offset) / ii.heightFactor;
-        final int totalDelta = (int) (mLastMotionY - mInitialMotionY);
-        int nextPage = determineTargetPage(currentPage, pageOffset, initialVelocity,
-                totalDelta);
-        setCurrentItemInternal(nextPage, true, true, initialVelocity);
-        endDrag();
-
-        mFakeDragging = false;
-    }
-
-    /**
-     * Fake drag by an offset in pixels. You must have called {@link #beginFakeDrag()} first.
-     *
-     * @param yOffset Offset in pixels to drag by.
-     * @see #beginFakeDrag()
-     * @see #endFakeDrag()
-     */
-    public void fakeDragBy(float yOffset) {
-        if (!mFakeDragging) {
-            throw new IllegalStateException("No fake drag in progress. Call beginFakeDrag first.");
-        }
-
-        mLastMotionY += yOffset;
-
-        float oldScrollY = getScrollY();
-        float scrollY = oldScrollY - yOffset;
-        final int height = getClientHeight();
-
-        float topBound = height * mFirstOffset;
-        float bottomBound = height * mLastOffset;
-
-        final ItemInfo firstItem = mItems.get(0);
-        final ItemInfo lastItem = mItems.get(mItems.size() - 1);
-        if (firstItem.position != 0) {
-            topBound = firstItem.offset * height;
-        }
-        if (lastItem.position != mAdapter.getCount() - 1) {
-            bottomBound = lastItem.offset * height;
-        }
-
-        if (scrollY < topBound) {
-            scrollY = topBound;
-        } else if (scrollY > bottomBound) {
-            scrollY = bottomBound;
-        }
-        // Don't lose the rounded component
-        mLastMotionY += scrollY - (int) scrollY;
-        scrollTo(getScrollX(), (int) scrollY);
-        pageScrolled((int) scrollY);
-
-        // Synthesize an event for the VelocityTracker.
-        final long time = SystemClock.uptimeMillis();
-        final MotionEvent ev = MotionEvent.obtain(mFakeDragBeginTime, time, MotionEvent.ACTION_MOVE,
-                0, mLastMotionY, 0);
-        mVelocityTracker.addMovement(ev);
-        ev.recycle();
-    }
-
-    /**
-     * Returns true if a fake drag is in progress.
-     *
-     * @return true if currently in a fake drag, false otherwise.
-     *
-     * @see #beginFakeDrag()
-     * @see #fakeDragBy(float)
-     * @see #endFakeDrag()
-     */
-    public boolean isFakeDragging() {
-        return mFakeDragging;
-    }
-
-    private void onSecondaryPointerUp(MotionEvent ev) {
-        final int pointerIndex = MotionEventCompat.getActionIndex(ev);
-        final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
-        if (pointerId == mActivePointerId) {
-            // This was our active pointer going up. Choose a new
-            // active pointer and adjust accordingly.
-            final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
-            mLastMotionY = MotionEventCompat.getY(ev, newPointerIndex);
-            mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
-            if (mVelocityTracker != null) {
-                mVelocityTracker.clear();
-            }
-        }
-    }
-
-    private void endDrag() {
-        mIsBeingDragged = false;
-        mIsUnableToDrag = false;
-
-        if (mVelocityTracker != null) {
-            mVelocityTracker.recycle();
-            mVelocityTracker = null;
-        }
-    }
-
-    private void setScrollingCacheEnabled(boolean enabled) {
-        if (mScrollingCacheEnabled != enabled) {
-            mScrollingCacheEnabled = enabled;
-            if (USE_CACHE) {
-                final int size = getChildCount();
-                for (int i = 0; i < size; ++i) {
-                    final View child = getChildAt(i);
-                    if (child.getVisibility() != GONE) {
-                        child.setDrawingCacheEnabled(enabled);
-                    }
-                }
-            }
-        }
-    }
-
-    public boolean internalCanScrollVertically(int direction) {
-        if (mAdapter == null) {
-            return false;
-        }
-
-        final int height = getClientHeight();
-        final int scrollY = getScrollY();
-        if (direction < 0) {
-            return (scrollY > (int) (height * mFirstOffset));
-        } else if (direction > 0) {
-            return (scrollY < (int) (height * mLastOffset));
-        } else {
-            return false;
-        }
-    }
-
-    /**
-     * Tests scrollability within child views of v given a delta of dx.
-     *
-     * @param v View to test for horizontal scrollability
-     * @param checkV Whether the view v passed should itself be checked for scrollability (true),
-     *               or just its children (false).
-     * @param dy Delta scrolled in pixels
-     * @param x X coordinate of the active touch point
-     * @param y Y coordinate of the active touch point
-     * @return true if child views of v can be scrolled by delta of dx.
-     */
-    protected boolean canScroll(View v, boolean checkV, int dy, int x, int y) {
-        if (v instanceof ViewGroup) {
-            final ViewGroup group = (ViewGroup) v;
-            final int scrollX = v.getScrollX();
-            final int scrollY = v.getScrollY();
-            final int count = group.getChildCount();
-            // Count backwards - let topmost views consume scroll distance first.
-            for (int i = count - 1; i >= 0; i--) {
-                // TODO: Add versioned support here for transformed views.
-                // This will not work for transformed views in Honeycomb+
-                final View child = group.getChildAt(i);
-                if (y + scrollY >= child.getTop() && y + scrollY < child.getBottom() &&
-                        x + scrollX >= child.getLeft() && x + scrollX < child.getRight() &&
-                        canScroll(child, true, dy, x + scrollX - child.getLeft(),
-                                y + scrollY - child.getTop())) {
-                    return true;
-                }
-            }
-        }
-
-        return checkV && ViewCompat.canScrollVertically(v, -dy);
-    }
-
-    @Override
-    public boolean dispatchKeyEvent(KeyEvent event) {
-        // Let the focused view and/or our descendants get the key first
-        return super.dispatchKeyEvent(event) || executeKeyEvent(event);
-    }
-
-    /**
-     * You can call this function yourself to have the scroll view perform
-     * scrolling from a key event, just as if the event had been dispatched to
-     * it by the view hierarchy.
-     *
-     * @param event The key event to execute.
-     * @return Return true if the event was handled, else false.
-     */
-    public boolean executeKeyEvent(KeyEvent event) {
-        boolean handled = false;
-        if (event.getAction() == KeyEvent.ACTION_DOWN) {
-            switch (event.getKeyCode()) {
-                case KeyEvent.KEYCODE_DPAD_LEFT:
-                    handled = arrowScroll(FOCUS_LEFT);
-                    break;
-                case KeyEvent.KEYCODE_DPAD_RIGHT:
-                    handled = arrowScroll(FOCUS_RIGHT);
-                    break;
-                case KeyEvent.KEYCODE_TAB:
-                    if (event.hasNoModifiers()) {
-                        handled = arrowScroll(FOCUS_FORWARD);
-                    } else if (event.hasModifiers(KeyEvent.META_SHIFT_ON)) {
-                        handled = arrowScroll(FOCUS_BACKWARD);
-                    }
-                    break;
-                default:
-                    break;
-            }
-        }
-        return handled;
-    }
-
-    public boolean arrowScroll(int direction) {
-        View currentFocused = findFocus();
-        if (currentFocused == this) {
-            currentFocused = null;
-        } else if (currentFocused != null) {
-            boolean isChild = false;
-            for (ViewParent parent = currentFocused.getParent(); parent instanceof ViewGroup;
-                 parent = parent.getParent()) {
-                if (parent == this) {
-                    isChild = true;
-                    break;
-                }
-            }
-            if (!isChild) {
-                // This would cause the focus search down below to fail in fun ways.
-                final StringBuilder sb = new StringBuilder();
-                sb.append(currentFocused.getClass().getSimpleName());
-                for (ViewParent parent = currentFocused.getParent(); parent instanceof ViewGroup;
-                     parent = parent.getParent()) {
-                    sb.append(" => ").append(parent.getClass().getSimpleName());
-                }
-                Log.e(TAG, "arrowScroll tried to find focus based on non-child " +
-                        "current focused view " + sb.toString());
-                currentFocused = null;
-            }
-        }
-
-        boolean handled = false;
-
-        View nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused,
-                direction);
-        if (nextFocused != null && nextFocused != currentFocused) {
-            if (direction == View.FOCUS_UP) {
-                // If there is nothing to the left, or this is causing us to
-                // jump to the right, then what we really want to do is page left.
-                final int nextTop = getChildRectInPagerCoordinates(mTempRect, nextFocused).top;
-                final int currTop = getChildRectInPagerCoordinates(mTempRect, currentFocused).top;
-                if (currentFocused != null && nextTop >= currTop) {
-                    handled = pageUp();
-                } else {
-                    handled = nextFocused.requestFocus();
-                }
-            } else if (direction == View.FOCUS_DOWN) {
-                // If there is nothing to the right, or this is causing us to
-                // jump to the left, then what we really want to do is page right.
-                final int nextDown = getChildRectInPagerCoordinates(mTempRect, nextFocused).bottom;
-                final int currDown = getChildRectInPagerCoordinates(mTempRect, currentFocused).bottom;
-                if (currentFocused != null && nextDown <= currDown) {
-                    handled = pageDown();
-                } else {
-                    handled = nextFocused.requestFocus();
-                }
-            }
-        } else if (direction == FOCUS_UP || direction == FOCUS_BACKWARD) {
-            // Trying to move left and nothing there; try to page.
-            handled = pageUp();
-        } else if (direction == FOCUS_DOWN || direction == FOCUS_FORWARD) {
-            // Trying to move right and nothing there; try to page.
-            handled = pageDown();
-        }
-        if (handled) {
-            playSoundEffect(SoundEffectConstants.getContantForFocusDirection(direction));
-        }
-        return handled;
-    }
-
-    private Rect getChildRectInPagerCoordinates(Rect outRect, View child) {
-        if (outRect == null) {
-            outRect = new Rect();
-        }
-        if (child == null) {
-            outRect.set(0, 0, 0, 0);
-            return outRect;
-        }
-        outRect.left = child.getLeft();
-        outRect.right = child.getRight();
-        outRect.top = child.getTop();
-        outRect.bottom = child.getBottom();
-
-        ViewParent parent = child.getParent();
-        while (parent instanceof ViewGroup && parent != this) {
-            final ViewGroup group = (ViewGroup) parent;
-            outRect.left += group.getLeft();
-            outRect.right += group.getRight();
-            outRect.top += group.getTop();
-            outRect.bottom += group.getBottom();
-
-            parent = group.getParent();
-        }
-        return outRect;
-    }
-
-    boolean pageUp() {
-        if (mCurItem > 0) {
-            setCurrentItem(mCurItem-1, true);
-            return true;
-        }
-        return false;
-    }
-
-    boolean pageDown() {
-        if (mAdapter != null && mCurItem < (mAdapter.getCount()-1)) {
-            setCurrentItem(mCurItem+1, true);
-            return true;
-        }
-        return false;
-    }
-
-    /**
-     * We only want the current page that is being shown to be focusable.
-     */
-    @Override
-    public void addFocusables(ArrayList<View> views, int direction, int focusableMode) {
-        final int focusableCount = views.size();
-
-        final int descendantFocusability = getDescendantFocusability();
-
-        if (descendantFocusability != FOCUS_BLOCK_DESCENDANTS) {
-            for (int i = 0; i < getChildCount(); i++) {
-                final View child = getChildAt(i);
-                if (child.getVisibility() == VISIBLE) {
-                    ItemInfo ii = infoForChild(child);
-                    if (ii != null && ii.position == mCurItem) {
-                        child.addFocusables(views, direction, focusableMode);
-                    }
-                }
-            }
-        }
-
-        // we add ourselves (if focusable) in all cases except for when we are
-        // FOCUS_AFTER_DESCENDANTS and there are some descendants focusable.  this is
-        // to avoid the focus search finding layouts when a more precise search
-        // among the focusable children would be more interesting.
-        if (
-                descendantFocusability != FOCUS_AFTER_DESCENDANTS ||
-                        // No focusable descendants
-                        (focusableCount == views.size())) {
-            // Note that we can't call the superclass here, because it will
-            // add all views in.  So we need to do the same thing View does.
-            if (!isFocusable()) {
-                return;
-            }
-            if ((focusableMode & FOCUSABLES_TOUCH_MODE) == FOCUSABLES_TOUCH_MODE &&
-                    isInTouchMode() && !isFocusableInTouchMode()) {
-                return;
-            }
-            if (views != null) {
-                views.add(this);
-            }
-        }
-    }
-
-    /**
-     * We only want the current page that is being shown to be touchable.
-     */
-    @Override
-    public void addTouchables(ArrayList<View> views) {
-        // Note that we don't call super.addTouchables(), which means that
-        // we don't call View.addTouchables().  This is okay because a ViewPager
-        // is itself not touchable.
-        for (int i = 0; i < getChildCount(); i++) {
-            final View child = getChildAt(i);
-            if (child.getVisibility() == VISIBLE) {
-                ItemInfo ii = infoForChild(child);
-                if (ii != null && ii.position == mCurItem) {
-                    child.addTouchables(views);
-                }
-            }
-        }
-    }
-
-    /**
-     * We only want the current page that is being shown to be focusable.
-     */
-    @Override
-    protected boolean onRequestFocusInDescendants(int direction,
-                                                  Rect previouslyFocusedRect) {
-        int index;
-        int increment;
-        int end;
-        int count = getChildCount();
-        if ((direction & FOCUS_FORWARD) != 0) {
-            index = 0;
-            increment = 1;
-            end = count;
-        } else {
-            index = count - 1;
-            increment = -1;
-            end = -1;
-        }
-        for (int i = index; i != end; i += increment) {
-            View child = getChildAt(i);
-            if (child.getVisibility() == VISIBLE) {
-                ItemInfo ii = infoForChild(child);
-                if (ii != null && ii.position == mCurItem && child.requestFocus(direction, previouslyFocusedRect)) {
-                    return true;
-                }
-            }
-        }
-        return false;
-    }
-
-    @Override
-    public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
-        // Dispatch scroll events from this ViewPager.
-        if (event.getEventType() == AccessibilityEventCompat.TYPE_VIEW_SCROLLED) {
-            return super.dispatchPopulateAccessibilityEvent(event);
-        }
-
-        // Dispatch all other accessibility events from the current page.
-        final int childCount = getChildCount();
-        for (int i = 0; i < childCount; i++) {
-            final View child = getChildAt(i);
-            if (child.getVisibility() == VISIBLE) {
-                final ItemInfo ii = infoForChild(child);
-                if (ii != null && ii.position == mCurItem &&
-                        child.dispatchPopulateAccessibilityEvent(event)) {
-                    return true;
-                }
-            }
-        }
-
-        return false;
-    }
-
-    @Override
-    protected ViewGroup.LayoutParams generateDefaultLayoutParams() {
-        return new LayoutParams();
-    }
-
-    @Override
-    protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
-        return generateDefaultLayoutParams();
-    }
-
-    @Override
-    protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
-        return p instanceof LayoutParams && super.checkLayoutParams(p);
-    }
-
-    @Override
-    public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) {
-        return new LayoutParams(getContext(), attrs);
-    }
-
-    class MyAccessibilityDelegate extends AccessibilityDelegateCompat {
-
-        @Override
-        public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) {
-            super.onInitializeAccessibilityEvent(host, event);
-            event.setClassName(VerticalViewPagerImpl.class.getName());
-            final AccessibilityRecordCompat recordCompat = AccessibilityRecordCompat.obtain();
-            recordCompat.setScrollable(canScroll());
-            if (event.getEventType() == AccessibilityEventCompat.TYPE_VIEW_SCROLLED
-                    && mAdapter != null) {
-                recordCompat.setItemCount(mAdapter.getCount());
-                recordCompat.setFromIndex(mCurItem);
-                recordCompat.setToIndex(mCurItem);
-            }
-        }
-
-        @Override
-        public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) {
-            super.onInitializeAccessibilityNodeInfo(host, info);
-            info.setClassName(VerticalViewPagerImpl.class.getName());
-            info.setScrollable(canScroll());
-            if (internalCanScrollVertically(1)) {
-                info.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD);
-            }
-            if (internalCanScrollVertically(-1)) {
-                info.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);
-            }
-        }
-
-        @Override
-        public boolean performAccessibilityAction(View host, int action, Bundle args) {
-            if (super.performAccessibilityAction(host, action, args)) {
-                return true;
-            }
-            switch (action) {
-                case AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD: {
-                    if (internalCanScrollVertically(1)) {
-                        setCurrentItem(mCurItem + 1);
-                        return true;
-                    }
-                } return false;
-                case AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD: {
-                    if (internalCanScrollVertically(-1)) {
-                        setCurrentItem(mCurItem - 1);
-                        return true;
-                    }
-                } return false;
-                default:
-                    break;
-            }
-            return false;
-        }
-
-        private boolean canScroll() {
-            return (mAdapter != null) && (mAdapter.getCount() > 1);
-        }
-    }
-
-    private class PagerObserver extends DataSetObserver {
-        @Override
-        public void onChanged() {
-            dataSetChanged();
-        }
-        @Override
-        public void onInvalidated() {
-            dataSetChanged();
-        }
-    }
-
-    /**
-     * Layout parameters that should be supplied for views added to a
-     * ViewPager.
-     */
-    public static class LayoutParams extends ViewGroup.LayoutParams {
-        /**
-         * true if this view is a decoration on the pager itself and not
-         * a view supplied by the adapter.
-         */
-        public boolean isDecor;
-
-        /**
-         * Gravity setting for use on decor views only:
-         * Where to position the view page within the overall ViewPager
-         * container; constants are defined in {@link android.view.Gravity}.
-         */
-        public int gravity;
-
-        /**
-         * Width as a 0-1 multiplier of the measured pager width
-         */
-        private float heightFactor = 0.f;
-
-        /**
-         * true if this view was added during layout and needs to be measured
-         * before being positioned.
-         */
-        private boolean needsMeasure;
-
-        /**
-         * Adapter position this view is for if !isDecor
-         */
-        private int position;
-
-        /**
-         * Current child index within the ViewPager that this view occupies
-         */
-        private int childIndex;
-
-        public LayoutParams() {
-            super(FILL_PARENT, FILL_PARENT);
-        }
-
-        public LayoutParams(Context context, AttributeSet attrs) {
-            super(context, attrs);
-
-            final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS);
-            gravity = a.getInteger(0, Gravity.TOP);
-            a.recycle();
-        }
-    }
-
-    static class ViewPositionComparator implements Comparator<View> {
-        @Override
-        public int compare(View lhs, View rhs) {
-            final LayoutParams llp = (LayoutParams) lhs.getLayoutParams();
-            final LayoutParams rlp = (LayoutParams) rhs.getLayoutParams();
-            if (llp.isDecor != rlp.isDecor) {
-                return llp.isDecor ? 1 : -1;
-            }
-            return llp.position - rlp.position;
-        }
-    }
-}

+ 136 - 42
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt

@@ -1,78 +1,172 @@
 package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
 
+import android.support.v7.util.DiffUtil
 import android.support.v7.widget.RecyclerView
-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 android.widget.FrameLayout
+import android.widget.LinearLayout
+import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
+import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
+import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
 
 /**
- * Adapter of pages for a RecyclerView.
- *
- * @param fragment the fragment containing this adapter.
+ * RecyclerView Adapter used by this [viewer] to where [ViewerChapters] updates are posted.
  */
-class WebtoonAdapter(val fragment: WebtoonReader) : RecyclerView.Adapter<WebtoonHolder>() {
+class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 
     /**
-     * Pages stored in the adapter.
+     * List of currently set items.
      */
-    var pages: List<Page>? = null
+    var items: List<Any> = emptyList()
+        private set
 
     /**
-     * Touch listener for images in holders.
+     * Updates this adapter with the given [chapters]. It handles setting a few pages of the
+     * next/previous chapter to allow seamless transitions.
      */
-    val touchListener = View.OnTouchListener { _, ev -> fragment.imageGestureDetector.onTouchEvent(ev) }
+    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))
+            }
+        }
+
+        val result = DiffUtil.calculateDiff(Callback(items, newItems))
+        items = newItems
+        result.dispatchUpdatesTo(this)
+    }
 
     /**
-     * Returns the number of pages.
-     *
-     * @return the number of pages or 0 if the list is null.
+     * Returns the amount of items of the adapter.
      */
     override fun getItemCount(): Int {
-        return pages?.size ?: 0
+        return items.size
+    }
+
+    /**
+     * Returns the view type for the item at the given [position].
+     */
+    override fun getItemViewType(position: Int): Int {
+        val item = items[position]
+        return when (item) {
+            is ReaderPage -> PAGE_VIEW
+            is ChapterTransition -> TRANSITION_VIEW
+            else -> error("Unknown view type for ${item.javaClass}")
+        }
     }
 
     /**
-     * Returns a page given the position.
-     *
-     * @param position the position of the page.
-     * @return the page.
+     * Creates a new view holder for an item with the given [viewType].
      */
-    fun getItem(position: Int): Page {
-        return pages!![position]
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+        return when (viewType) {
+            PAGE_VIEW -> {
+                val view = FrameLayout(parent.context)
+                WebtoonPageHolder(view, viewer)
+            }
+            TRANSITION_VIEW -> {
+                val view = LinearLayout(parent.context)
+                WebtoonTransitionHolder(view, viewer)
+            }
+            else -> error("Unknown view type")
+        }
     }
 
     /**
-     * Creates a new view holder.
-     *
-     * @param parent the parent view.
-     * @param viewType the type of the holder.
-     * @return a new view holder for a manga.
+     * Binds an existing view [holder] with the item at the given [position].
      */
-    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WebtoonHolder {
-        val v = parent.inflate(R.layout.reader_webtoon_item)
-        return WebtoonHolder(v, this)
+    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+        val item = items[position]
+        when (holder) {
+            is WebtoonPageHolder -> holder.bind(item as ReaderPage)
+            is WebtoonTransitionHolder -> holder.bind(item as ChapterTransition)
+        }
     }
 
     /**
-     * Binds a holder with a new position.
-     *
-     * @param holder the holder to bind.
-     * @param position the position to bind.
+     * Recycles an existing view [holder] before adding it to the view pool.
      */
-    override fun onBindViewHolder(holder: WebtoonHolder, position: Int) {
-        val page = getItem(position)
-        holder.onSetValues(page)
+    override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
+        when (holder) {
+            is WebtoonPageHolder -> holder.recycle()
+            is WebtoonTransitionHolder -> holder.recycle()
+        }
     }
 
     /**
-     * Recycles the view holder.
-     *
-     * @param holder the holder to recycle.
+     * Diff util callback used to dispatch delta updates instead of full dataset changes.
      */
-    override fun onViewRecycled(holder: WebtoonHolder) {
-        holder.onRecycle()
+    private class Callback(
+            private val oldItems: List<Any>,
+            private val newItems: List<Any>
+    ) : DiffUtil.Callback() {
+
+        /**
+         * Returns true if these two items are the same.
+         */
+        override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
+            val oldItem = oldItems[oldItemPosition]
+            val newItem = newItems[newItemPosition]
+
+            return oldItem == newItem
+        }
+
+        /**
+         * Returns true if the contents of the items are the same.
+         */
+        override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
+            return true
+        }
+
+        /**
+         * Returns the size of the old list.
+         */
+        override fun getOldListSize(): Int {
+            return oldItems.size
+        }
+
+        /**
+         * Returns the size of the new list.
+         */
+        override fun getNewListSize(): Int {
+            return newItems.size
+        }
+    }
+
+    private companion object {
+        /**
+         * View holder type of a chapter page view.
+         */
+        const val PAGE_VIEW = 0
+
+        /**
+         * View holder type of a chapter transition view.
+         */
+        const val TRANSITION_VIEW = 1
     }
 
 }

+ 46 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonBaseHolder.kt

@@ -0,0 +1,46 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+import android.content.Context
+import android.view.View
+import android.view.ViewGroup.LayoutParams
+import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder
+import rx.Subscription
+
+abstract class WebtoonBaseHolder(
+        view: View,
+        protected val viewer: WebtoonViewer
+) : BaseViewHolder(view) {
+
+    /**
+     * Context getter because it's used often.
+     */
+    val context: Context get() = itemView.context
+
+    /**
+     * Called when the view is recycled and being added to the view pool.
+     */
+    open fun recycle() {}
+
+    /**
+     * Adds a subscription to a list of subscriptions that will automatically unsubscribe when the
+     * activity or the reader is destroyed.
+     */
+    protected fun addSubscription(subscription: Subscription?) {
+        viewer.subscriptions.add(subscription)
+    }
+
+    /**
+     * Removes a subscription from the list of subscriptions.
+     */
+    protected fun removeSubscription(subscription: Subscription?) {
+        subscription?.let { viewer.subscriptions.remove(it) }
+    }
+
+    /**
+     * Extension method to set layout params to wrap content on this view.
+     */
+    protected fun View.wrapContent() {
+        layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
+    }
+
+}

+ 68 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt

@@ -0,0 +1,68 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+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 webtoon viewers.
+ */
+class WebtoonConfig(preferences: PreferencesHelper = Injekt.get()) {
+
+    private val subscriptions = CompositeSubscription()
+
+    var imagePropertyChangedListener: (() -> Unit)? = null
+
+    var tappingEnabled = true
+        private set
+
+    var volumeKeysEnabled = false
+        private set
+
+    var volumeKeysInverted = false
+        private set
+
+    var imageCropBorders = false
+        private set
+
+    var doubleTapAnimDuration = 500
+        private set
+
+    init {
+        preferences.readWithTapping()
+            .register({ tappingEnabled = it })
+
+        preferences.cropBordersWebtoon()
+            .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)
+    }
+
+}

+ 80 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonFrame.kt

@@ -0,0 +1,80 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+import android.content.Context
+import android.view.GestureDetector
+import android.view.MotionEvent
+import android.view.ScaleGestureDetector
+import android.widget.FrameLayout
+
+/**
+ * Frame layout which contains a [WebtoonRecyclerView]. It's needed to handle touch events,
+ * because the recyclerview is scaled and its touch events are translated, which breaks the
+ * detectors.
+ *
+ * TODO consider integrating this class into [WebtoonViewer].
+ */
+class WebtoonFrame(context: Context) : FrameLayout(context) {
+
+    /**
+     * Scale detector, either with pinch or quick scale.
+     */
+    private val scaleDetector = ScaleGestureDetector(context, ScaleListener())
+
+    /**
+     * Fling detector.
+     */
+    private val flingDetector = GestureDetector(context, FlingListener())
+
+    /**
+     * Recycler view added in this frame.
+     */
+    private val recycler: WebtoonRecyclerView?
+        get() = getChildAt(0) as? WebtoonRecyclerView
+
+    /**
+     * Dispatches a touch event to the detectors.
+     */
+    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
+        scaleDetector.onTouchEvent(ev)
+        flingDetector.onTouchEvent(ev)
+        return super.dispatchTouchEvent(ev)
+    }
+
+    /**
+     * Scale listener used to delegate events to the recycler view.
+     */
+    inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
+        override fun onScaleBegin(detector: ScaleGestureDetector?): Boolean {
+            recycler?.onScaleBegin()
+            return true
+        }
+
+        override fun onScale(detector: ScaleGestureDetector): Boolean {
+            recycler?.onScale(detector.scaleFactor)
+            return true
+        }
+
+        override fun onScaleEnd(detector: ScaleGestureDetector) {
+            recycler?.onScaleEnd()
+        }
+    }
+
+    /**
+     * Fling listener used to delegate events to the recycler view.
+     */
+    inner class FlingListener : GestureDetector.SimpleOnGestureListener() {
+        override fun onDown(e: MotionEvent?): Boolean {
+            return true
+        }
+
+        override fun onFling(
+                e1: MotionEvent?,
+                e2: MotionEvent?,
+                velocityX: Float,
+                velocityY: Float
+        ): Boolean {
+            return recycler?.zoomFling(velocityX.toInt(), velocityY.toInt()) ?: false
+        }
+    }
+
+}

+ 0 - 316
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt

@@ -1,316 +0,0 @@
-package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
-
-import android.view.MotionEvent
-import android.view.View
-import android.view.ViewGroup
-import android.view.ViewGroup.LayoutParams.MATCH_PARENT
-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.base.holder.BaseViewHolder
-import eu.kanade.tachiyomi.ui.reader.ReaderActivity
-import eu.kanade.tachiyomi.ui.reader.viewer.base.PageDecodeErrorLayout
-import eu.kanade.tachiyomi.util.inflate
-import kotlinx.android.synthetic.main.reader_webtoon_item.*
-import rx.Observable
-import rx.Subscription
-import rx.android.schedulers.AndroidSchedulers
-import rx.subjects.PublishSubject
-import rx.subjects.SerializedSubject
-import java.util.concurrent.TimeUnit
-
-/**
- * Holder for webtoon reader for a single page of a chapter.
- * All the elements from the layout file "reader_webtoon_item" are available in this class.
- *
- * @param view the inflated view for this holder.
- * @param adapter the adapter handling this holder.
- * @constructor creates a new webtoon holder.
- */
-class WebtoonHolder(private val view: View, private val adapter: WebtoonAdapter) :
-        BaseViewHolder(view) {
-
-    /**
-     * Page of a chapter.
-     */
-    private var page: Page? = 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
-
-    /**
-     * Layout of decode error.
-     */
-    private var decodeErrorLayout: View? = null
-
-    init {
-        with(image_view) {
-            setMaxTileSize(readerActivity.maxBitmapSize)
-            setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_FIXED)
-            setDoubleTapZoomDuration(webtoonReader.doubleTapAnimDuration.toInt())
-            setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
-            setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_FIT_WIDTH)
-            setMinimumDpi(90)
-            setMinimumTileDpi(180)
-            setRegionDecoderClass(webtoonReader.regionDecoderClass)
-            setBitmapDecoderClass(webtoonReader.bitmapDecoderClass)
-            setCropBorders(webtoonReader.cropBorders)
-            setVerticalScrollingParent(true)
-            setOnTouchListener(adapter.touchListener)
-            setOnLongClickListener { webtoonReader.onLongClick(page) }
-            setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
-                override fun onReady() {
-                    onImageDecoded()
-                }
-
-                override fun onImageLoadError(e: Exception) {
-                    onImageDecodeError()
-                }
-            })
-        }
-
-        progress_container.layoutParams = FrameLayout.LayoutParams(
-                MATCH_PARENT, webtoonReader.screenHeight)
-
-        view.setOnTouchListener(adapter.touchListener)
-        retry_button.setOnTouchListener { _, event ->
-            if (event.action == MotionEvent.ACTION_UP) {
-                readerActivity.presenter.retryPage(page)
-            }
-            true
-        }
-    }
-
-    /**
-     * Method called from [WebtoonAdapter.onBindViewHolder]. It updates the data for this
-     * holder with the given page.
-     *
-     * @param page the page to bind.
-     */
-    fun onSetValues(page: Page) {
-        this.page = page
-        observeStatus()
-    }
-
-    /**
-     * Called when the view is recycled and added to the view pool.
-     */
-    fun onRecycle() {
-        unsubscribeStatus()
-        unsubscribeProgress()
-        decodeErrorLayout?.let {
-            (view as ViewGroup).removeView(it)
-            decodeErrorLayout = null
-        }
-        image_view.recycle()
-        image_view.visibility = View.GONE
-        progress_container.visibility = View.VISIBLE
-    }
-
-    /**
-     * Observes the status of the page and notify the changes.
-     *
-     * @see processStatus
-     */
-    private fun observeStatus() {
-        unsubscribeStatus()
-
-        val page = page ?: return
-
-        val statusSubject = SerializedSubject(PublishSubject.create<Int>())
-        page.setStatusSubject(statusSubject)
-
-        statusSubscription = statusSubject.startWith(page.status)
-                .observeOn(AndroidSchedulers.mainThread())
-                .subscribe { processStatus(it) }
-
-        addSubscription(statusSubscription)
-    }
-
-    /**
-     * Observes the progress of the page and updates view.
-     */
-    private fun observeProgress() {
-        unsubscribeProgress()
-
-        val page = page ?: return
-
-        progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
-                .map { page.progress }
-                .distinctUntilChanged()
-                .onBackpressureLatest()
-                .observeOn(AndroidSchedulers.mainThread())
-                .subscribe { progress ->
-                    progress_text.text = if (progress > 0) {
-                        view.context.getString(R.string.download_progress, progress)
-                    } else {
-                        view.context.getString(R.string.downloading)
-                    }
-                }
-
-        addSubscription(progressSubscription)
-    }
-
-    /**
-     * 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()
-            }
-        }
-    }
-
-    /**
-     * Adds a subscription to a list of subscriptions that will automatically unsubscribe when the
-     * activity or the reader is destroyed.
-     */
-    private fun addSubscription(subscription: Subscription?) {
-        webtoonReader.subscriptions.add(subscription)
-    }
-
-    /**
-     * Removes a subscription from the list of subscriptions.
-     */
-    private fun removeSubscription(subscription: Subscription?) {
-        subscription?.let { webtoonReader.subscriptions.remove(it) }
-    }
-
-    /**
-     * Unsubscribes from the status subscription.
-     */
-    private fun unsubscribeStatus() {
-        page?.setStatusSubject(null)
-        removeSubscription(statusSubscription)
-        statusSubscription = null
-    }
-
-    /**
-     * Unsubscribes from the progress subscription.
-     */
-    private fun unsubscribeProgress() {
-        removeSubscription(progressSubscription)
-        progressSubscription = null
-    }
-
-    /**
-     * Called when the page is queued.
-     */
-    private fun setQueued() = with(view) {
-        progress_container.visibility = View.VISIBLE
-        progress_text.visibility = View.INVISIBLE
-        retry_container.visibility = View.GONE
-        decodeErrorLayout?.let {
-            (view as ViewGroup).removeView(it)
-            decodeErrorLayout = null
-        }
-    }
-
-    /**
-     * Called when the page is loading.
-     */
-    private fun setLoading() = with(view) {
-        progress_container.visibility = View.VISIBLE
-        progress_text.visibility = View.VISIBLE
-        progress_text.setText(R.string.downloading)
-    }
-
-    /**
-     * Called when the page is downloading
-     */
-    private fun setDownloading() = with(view) {
-        progress_container.visibility = View.VISIBLE
-        progress_text.visibility = View.VISIBLE
-    }
-
-    /**
-     * Called when the page is ready.
-     */
-    private fun setImage() = with(view) {
-        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
-        }
-
-        progress_text.visibility = View.INVISIBLE
-        image_view.visibility = View.VISIBLE
-        image_view.setImage(ImageSource.uri(file.uri))
-    }
-
-    /**
-     * Called when the page has an error.
-     */
-    private fun setError() = with(view) {
-        progress_container.visibility = View.GONE
-        retry_container.visibility = View.VISIBLE
-    }
-
-    /**
-     * Called when the image is decoded and going to be displayed.
-     */
-    private fun onImageDecoded() {
-        progress_container.visibility = View.GONE
-    }
-
-    /**
-     * Called when the image fails to decode.
-     */
-    private fun onImageDecodeError() {
-        progress_container.visibility = View.GONE
-
-        val page = page ?: return
-        if (decodeErrorLayout != null || !webtoonReader.isAdded) return
-
-        val layout = (view as ViewGroup).inflate(R.layout.reader_page_decode_error)
-        PageDecodeErrorLayout(layout, page, readerActivity.readerTheme, {
-            if (webtoonReader.isAdded) {
-                readerActivity.presenter.retryPage(page)
-            }
-        })
-        decodeErrorLayout = layout
-        view.addView(layout)
-    }
-
-    /**
-     * Property to get the reader activity.
-     */
-    private val readerActivity: ReaderActivity
-        get() = adapter.fragment.readerActivity
-
-    /**
-     * Property to get the webtoon reader.
-     */
-    private val webtoonReader: WebtoonReader
-        get() = adapter.fragment
-}

+ 55 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt

@@ -0,0 +1,55 @@
+@file:Suppress("PackageDirectoryMismatch")
+
+package android.support.v7.widget
+
+import android.support.v7.widget.RecyclerView.NO_POSITION
+import eu.kanade.tachiyomi.ui.reader.ReaderActivity
+
+/**
+ * Layout manager used by the webtoon viewer. Item prefetch is disabled because the extra layout
+ * space feature is used which allows setting the image even if the holder is not visible,
+ * avoiding (in most cases) black views when they are visible.
+ *
+ * This layout manager uses the same package name as the support library in order to use a package
+ * protected method.
+ */
+class WebtoonLayoutManager(activity: ReaderActivity) : LinearLayoutManager(activity) {
+
+    /**
+     * Extra layout space is set to half the screen height.
+     */
+    private val extraLayoutSpace = activity.resources.displayMetrics.heightPixels / 2
+
+    init {
+        isItemPrefetchEnabled = false
+    }
+
+    /**
+     * Returns the custom extra layout space.
+     */
+    override fun getExtraLayoutSpace(state: RecyclerView.State): Int {
+        return extraLayoutSpace
+    }
+
+    /**
+     * Returns the position of the last item whose end side is visible on screen.
+     */
+    fun findLastEndVisibleItemPosition(): Int {
+        ensureLayoutState()
+        @ViewBoundsCheck.ViewBounds val preferredBoundsFlag =
+                (ViewBoundsCheck.FLAG_CVE_LT_PVE or ViewBoundsCheck.FLAG_CVE_EQ_PVE)
+
+        val fromIndex = childCount - 1
+        val toIndex = -1
+
+        val child = if (mOrientation == HORIZONTAL)
+            mHorizontalBoundCheck
+                .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0)
+        else
+            mVerticalBoundCheck
+                .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0)
+
+        return if (child == null) NO_POSITION else getPosition(child)
+    }
+
+}

+ 504 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt

@@ -0,0 +1,504 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.graphics.drawable.Drawable
+import android.net.Uri
+import android.support.v7.widget.AppCompatButton
+import android.support.v7.widget.AppCompatImageView
+import android.view.Gravity
+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.request.RequestListener
+import com.bumptech.glide.request.target.Target
+import com.davemorrissey.labs.subscaleview.ImageSource
+import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
+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.util.ImageUtil
+import eu.kanade.tachiyomi.util.dpToPx
+import eu.kanade.tachiyomi.util.gone
+import eu.kanade.tachiyomi.util.visible
+import rx.Observable
+import rx.Subscription
+import rx.android.schedulers.AndroidSchedulers
+import rx.schedulers.Schedulers
+import java.io.InputStream
+import java.util.concurrent.TimeUnit
+
+/**
+ * Holder of the webtoon reader for a single page of a chapter.
+ *
+ * @param frame the root view for this holder.
+ * @param viewer the webtoon viewer.
+ * @constructor creates a new webtoon holder.
+ */
+class WebtoonPageHolder(
+        private val frame: FrameLayout,
+        viewer: WebtoonViewer
+) : WebtoonBaseHolder(frame, viewer) {
+
+    /**
+     * Loading progress bar to indicate the current progress.
+     */
+    private val progressBar = createProgressBar()
+
+    /**
+     * Progress bar container. Needed to keep a minimum height size of the holder, otherwise the
+     * adapter would create more views to fill the screen, which is not wanted.
+     */
+    private lateinit var progressContainer: ViewGroup
+
+    /**
+     * 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 container used to allow retrying.
+     */
+    private var retryContainer: ViewGroup? = null
+
+    /**
+     * Error layout to show when the image fails to decode.
+     */
+    private var decodeErrorLayout: ViewGroup? = null
+
+    /**
+     * Getter to retrieve the height of the recycler view.
+     */
+    private val parentHeight
+        get() = viewer.recycler.height
+
+    /**
+     * Page of a chapter.
+     */
+    private var page: ReaderPage? = 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 {
+        frame.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+    }
+
+    /**
+     * Binds the given [page] with this view holder, subscribing to its state.
+     */
+    fun bind(page: ReaderPage) {
+        this.page = page
+        observeStatus()
+    }
+
+    /**
+     * Called when the view is recycled and added to the view pool.
+     */
+    override fun recycle() {
+        unsubscribeStatus()
+        unsubscribeProgress()
+        unsubscribeReadImageHeader()
+
+        removeDecodeErrorLayout()
+        subsamplingImageView?.recycle()
+        subsamplingImageView?.gone()
+        imageView?.let { GlideApp.with(frame).clear(it) }
+        imageView?.gone()
+        progressBar.setProgress(0)
+    }
+
+    /**
+     * Observes the status of the page and notify the changes.
+     *
+     * @see processStatus
+     */
+    private fun observeStatus() {
+        unsubscribeStatus()
+
+        val page = page ?: return
+        val loader = page.chapter.pageLoader ?: return
+        statusSubscription = loader.getPage(page)
+            .observeOn(AndroidSchedulers.mainThread())
+            .subscribe { processStatus(it) }
+
+        addSubscription(statusSubscription)
+    }
+
+    /**
+     * Observes the progress of the page and updates view.
+     */
+    private fun observeProgress() {
+        unsubscribeProgress()
+
+        val page = page ?: return
+
+        progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
+            .map { page.progress }
+            .distinctUntilChanged()
+            .onBackpressureLatest()
+            .observeOn(AndroidSchedulers.mainThread())
+            .subscribe { value -> progressBar.setProgress(value) }
+
+        addSubscription(progressSubscription)
+    }
+
+    /**
+     * 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() {
+        removeSubscription(statusSubscription)
+        statusSubscription = null
+    }
+
+    /**
+     * Unsubscribes from the progress subscription.
+     */
+    private fun unsubscribeProgress() {
+        removeSubscription(progressSubscription)
+        progressSubscription = null
+    }
+
+    /**
+     * Unsubscribes from the read image header subscription.
+     */
+    private fun unsubscribeReadImageHeader() {
+        removeSubscription(readImageHeaderSubscription)
+        readImageHeaderSubscription = null
+    }
+
+    /**
+     * Called when the page is queued.
+     */
+    private fun setQueued() {
+        progressContainer.visible()
+        progressBar.visible()
+        retryContainer?.gone()
+        removeDecodeErrorLayout()
+    }
+
+    /**
+     * Called when the page is loading.
+     */
+    private fun setLoading() {
+        progressContainer.visible()
+        progressBar.visible()
+        retryContainer?.gone()
+        removeDecodeErrorLayout()
+    }
+
+    /**
+     * Called when the page is downloading
+     */
+    private fun setDownloading() {
+        progressContainer.visible()
+        progressBar.visible()
+        retryContainer?.gone()
+        removeDecodeErrorLayout()
+    }
+
+    /**
+     * Called when the page is ready.
+     */
+    private fun setImage() {
+        progressContainer.visible()
+        progressBar.visible()
+        progressBar.completeAndFadeOut()
+        retryContainer?.gone()
+        removeDecodeErrorLayout()
+
+        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) {
+                    val subsamplingView = initSubsamplingImageView()
+                    subsamplingView.visible()
+                    subsamplingView.setImage(ImageSource.inputStream(openStream!!))
+                } else {
+                    val imageView = initImageView()
+                    imageView.visible()
+                    imageView.setImage(openStream!!)
+                }
+            }
+            // Keep the Rx stream alive to close the input stream only when unsubscribed
+            .flatMap { Observable.never<Unit>() }
+            .doOnUnsubscribe { openStream?.close() }
+            .subscribe({}, {})
+
+        addSubscription(readImageHeaderSubscription)
+    }
+
+    /**
+     * Called when the page has an error.
+     */
+    private fun setError() {
+        progressContainer.gone()
+        initRetryLayout().visible()
+    }
+
+    /**
+     * Called when the image is decoded and going to be displayed.
+     */
+    private fun onImageDecoded() {
+        progressContainer.gone()
+    }
+
+    /**
+     * Called when the image fails to decode.
+     */
+    private fun onImageDecodeError() {
+        progressContainer.gone()
+        initDecodeErrorLayout().visible()
+    }
+
+    /**
+     * Creates a new progress bar.
+     */
+    @SuppressLint("PrivateResource")
+    private fun createProgressBar(): ReaderProgressBar {
+        progressContainer = FrameLayout(context)
+        frame.addView(progressContainer, MATCH_PARENT, parentHeight)
+
+        val progress = ReaderProgressBar(context).apply {
+            val size = 48.dpToPx
+            layoutParams = FrameLayout.LayoutParams(size, size).apply {
+                gravity = Gravity.CENTER_HORIZONTAL
+                setMargins(0, parentHeight/4, 0, 0)
+            }
+        }
+        progressContainer.addView(progress)
+        return progress
+    }
+
+    /**
+     * Initializes a subsampling scale view.
+     */
+    private fun initSubsamplingImageView(): SubsamplingScaleImageView {
+        if (subsamplingImageView != null) return subsamplingImageView!!
+
+        val config = viewer.config
+
+        subsamplingImageView = WebtoonSubsamplingImageView(context).apply {
+            setMaxTileSize(viewer.activity.maxBitmapSize)
+            setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
+            setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_FIT_WIDTH)
+            setMinimumDpi(90)
+            setMinimumTileDpi(180)
+            setCropBorders(config.imageCropBorders)
+            setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
+                override fun onReady() {
+                    onImageDecoded()
+                }
+
+                override fun onImageLoadError(e: Exception) {
+                    onImageDecodeError()
+                }
+            })
+        }
+        frame.addView(subsamplingImageView, MATCH_PARENT, MATCH_PARENT)
+        return subsamplingImageView!!
+    }
+
+    /**
+     * Initializes an image view, used for GIFs.
+     */
+    private fun initImageView(): ImageView {
+        if (imageView != null) return imageView!!
+
+        imageView = AppCompatImageView(context).apply {
+            adjustViewBounds = true
+        }
+        frame.addView(imageView, MATCH_PARENT, MATCH_PARENT)
+        return imageView!!
+    }
+
+    /**
+     * Initializes a button to retry pages.
+     */
+    private fun initRetryLayout(): ViewGroup {
+        if (retryContainer != null) return retryContainer!!
+
+        retryContainer = FrameLayout(context)
+        frame.addView(retryContainer, MATCH_PARENT, parentHeight)
+
+        AppCompatButton(context).apply {
+            layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
+                gravity = Gravity.CENTER_HORIZONTAL
+                setMargins(0, parentHeight/4, 0, 0)
+            }
+            setText(R.string.action_retry)
+            setOnClickListener {
+                page?.let { it.chapter.pageLoader?.retryPage(it) }
+            }
+
+            retryContainer!!.addView(this)
+        }
+        return retryContainer!!
+    }
+
+    /**
+     * Initializes a decode error layout.
+     */
+    private fun initDecodeErrorLayout(): ViewGroup {
+        if (decodeErrorLayout != null) return decodeErrorLayout!!
+
+        val margins = 8.dpToPx
+
+        val decodeLayout = LinearLayout(context).apply {
+            layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, parentHeight).apply {
+                setMargins(0, parentHeight/6, 0, 0)
+            }
+            gravity = Gravity.CENTER_HORIZONTAL
+            orientation = LinearLayout.VERTICAL
+        }
+        decodeErrorLayout = decodeLayout
+
+        TextView(context).apply {
+            layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
+                setMargins(0, margins, 0, margins)
+            }
+            gravity = Gravity.CENTER
+            setText(R.string.decode_image_error)
+
+            decodeLayout.addView(this)
+        }
+
+        AppCompatButton(context).apply {
+            layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
+                setMargins(0, margins, 0, margins)
+            }
+            setText(R.string.action_retry)
+            setOnClickListener {
+                page?.let { it.chapter.pageLoader?.retryPage(it) }
+            }
+
+            decodeLayout.addView(this)
+        }
+
+        val imageUrl = page?.imageUrl
+        if (imageUrl.orEmpty().startsWith("http")) {
+            AppCompatButton(context).apply {
+                layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
+                    setMargins(0, margins, 0, margins)
+                }
+                setText(R.string.action_open_in_browser)
+                setOnClickListener {
+                    val intent = Intent(Intent.ACTION_VIEW, Uri.parse(imageUrl))
+                    context.startActivity(intent)
+                }
+
+                decodeLayout.addView(this)
+            }
+        }
+
+        frame.addView(decodeLayout)
+        return decodeLayout
+    }
+
+    /**
+     * Removes the decode error layout from the holder, if found.
+     */
+    private fun removeDecodeErrorLayout() {
+        val layout = decodeErrorLayout
+        if (layout != null) {
+            frame.removeView(layout)
+            decodeErrorLayout = null
+        }
+    }
+
+    /**
+     * 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)
+            .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)
+    }
+
+}

+ 0 - 263
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt

@@ -1,263 +0,0 @@
-package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
-
-import android.os.Build
-import android.os.Bundle
-import android.support.v7.widget.RecyclerView
-import android.util.DisplayMetrics
-import android.view.Display
-import android.view.GestureDetector
-import android.view.LayoutInflater
-import android.view.MotionEvent
-import android.view.View
-import android.view.ViewGroup
-import android.view.ViewGroup.LayoutParams.MATCH_PARENT
-import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
-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.widget.PreCachingLayoutManager
-import rx.subscriptions.CompositeSubscription
-
-/**
- * Implementation of a reader for webtoons based on a RecyclerView.
- */
-class WebtoonReader : BaseReader() {
-
-    companion object {
-        /**
-         * Key to save and restore the position of the layout manager.
-         */
-        private val SAVED_POSITION = "saved_position"
-
-        /**
-         * Left side region of the screen. Used for touch events.
-         */
-        private val LEFT_REGION = 0.33f
-
-        /**
-         * Right side region of the screen. Used for touch events.
-         */
-        private val RIGHT_REGION = 0.66f
-    }
-
-    /**
-     * RecyclerView of the reader.
-     */
-    lateinit var recycler: RecyclerView
-        private set
-
-    /**
-     * Adapter of the recycler.
-     */
-    lateinit var adapter: WebtoonAdapter
-        private set
-
-    /**
-     * Layout manager of the recycler.
-     */
-    lateinit var layoutManager: PreCachingLayoutManager
-        private set
-
-    /**
-     * Whether to crop image borders.
-     */
-    var cropBorders: Boolean = false
-        private set
-
-    /**
-     * Duration of the double tap animation
-     */
-    var doubleTapAnimDuration = 500
-        private set
-
-    /**
-     * Gesture detector for image touch events.
-     */
-    val imageGestureDetector by lazy { GestureDetector(context, ImageGestureListener()) }
-
-    /**
-     * Subscriptions used while the view exists.
-     */
-    lateinit var subscriptions: CompositeSubscription
-        private set
-
-    private var scrollDistance: Int = 0
-
-    val screenHeight by lazy {
-        val display = activity!!.windowManager.defaultDisplay
-
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
-            val metrics = DisplayMetrics()
-            display.getRealMetrics(metrics)
-            metrics.heightPixels
-        } else {
-            val field = Display::class.java.getMethod("getRawHeight")
-            field.invoke(display) as Int
-        }
-    }
-
-    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
-        adapter = WebtoonAdapter(this)
-
-        val screenHeight = resources.displayMetrics.heightPixels
-        scrollDistance = screenHeight * 3 / 4
-
-        layoutManager = PreCachingLayoutManager(activity!!)
-        layoutManager.extraLayoutSpace = screenHeight / 2
-
-        recycler = RecyclerView(activity).apply {
-            layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
-            itemAnimator = null
-        }
-        recycler.layoutManager = layoutManager
-        recycler.adapter = adapter
-        recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
-            override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) {
-                val index = layoutManager.findLastVisibleItemPosition()
-                if (index != currentPage) {
-                    pages.getOrNull(index)?.let { onPageChanged(index) }
-                }
-            }
-        })
-
-        subscriptions = CompositeSubscription()
-        subscriptions.add(readerActivity.preferences.imageDecoder()
-                .asObservable()
-                .doOnNext { setDecoderClass(it) }
-                .skip(1)
-                .distinctUntilChanged()
-                .subscribe { refreshAdapter() })
-
-        subscriptions.add(readerActivity.preferences.cropBordersWebtoon()
-                .asObservable()
-                .doOnNext { cropBorders = it }
-                .skip(1)
-                .distinctUntilChanged()
-                .subscribe { refreshAdapter() })
-
-        subscriptions.add(readerActivity.preferences.doubleTapAnimSpeed()
-                .asObservable()
-                .subscribe { doubleTapAnimDuration = it })
-
-        setPagesOnAdapter()
-        return recycler
-    }
-
-    fun refreshAdapter() {
-        val activePage = layoutManager.findFirstVisibleItemPosition()
-        recycler.adapter = adapter
-        setActivePage(activePage)
-    }
-
-    /**
-     * Uses two ways to scroll to the last page read.
-     */
-    private fun scrollToLastPageRead(page: Int) {
-        // Scrolls to the correct page initially, but isn't reliable beyond that.
-        recycler.addOnLayoutChangeListener(object: View.OnLayoutChangeListener {
-            override fun onLayoutChange(p0: View?, p1: Int, p2: Int, p3: Int, p4: Int, p5: Int, p6: Int, p7: Int, p8: Int) {
-                if(pages.isEmpty()) {
-                    setActivePage(page)
-                } else {
-                    recycler.removeOnLayoutChangeListener(this)
-                }
-            }
-        })
-
-        // Scrolls to the correct page after app has been in use, but can't do it the very first time.
-        recycler.post { setActivePage(page) }
-    }
-
-    override fun onDestroyView() {
-        subscriptions.unsubscribe()
-        super.onDestroyView()
-    }
-
-    override fun onSaveInstanceState(outState: Bundle) {
-        val savedPosition = pages.getOrNull(layoutManager.findFirstVisibleItemPosition())?.index ?: 0
-        outState.putInt(SAVED_POSITION, savedPosition)
-        super.onSaveInstanceState(outState)
-    }
-
-    /**
-     * 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 < recycler.width * LEFT_REGION) {
-                    if (tappingEnabled) moveLeft()
-                } else if (positionX > recycler.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 = currentPage.index
-
-        // Make sure the view is already initialized.
-        if (view != null) {
-            setPagesOnAdapter()
-            scrollToLastPageRead(this.currentPage)
-        }
-    }
-
-    /**
-     * 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) {
-            val insertStart = pages.size - chapter.pages!!.size
-            adapter.notifyItemRangeInserted(insertStart, chapter.pages!!.size)
-        }
-    }
-
-    /**
-     * Sets the pages on the adapter.
-     */
-    private fun setPagesOnAdapter() {
-        if (pages.isNotEmpty()) {
-            adapter.pages = pages
-            recycler.adapter = adapter
-            onPageChanged(currentPage)
-        }
-    }
-
-    /**
-     * Sets the active page.
-     * @param pageNumber the index of the page from [pages].
-     */
-    override fun setActivePage(pageNumber: Int) {
-        recycler.scrollToPosition(pageNumber)
-    }
-
-    /**
-     * Moves to the next page or requests the next chapter if it's the last one.
-     */
-    override fun moveRight() {
-        recycler.smoothScrollBy(0, scrollDistance)
-    }
-
-    /**
-     * Moves to the previous page or requests the previous chapter if it's the first one.
-     */
-    override fun moveLeft() {
-        recycler.smoothScrollBy(0, -scrollDistance)
-    }
-
-}

+ 325 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt

@@ -0,0 +1,325 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+import android.animation.Animator
+import android.animation.AnimatorSet
+import android.animation.ValueAnimator
+import android.annotation.TargetApi
+import android.content.Context
+import android.os.Build
+import android.support.v7.widget.LinearLayoutManager
+import android.support.v7.widget.RecyclerView
+import android.util.AttributeSet
+import android.view.HapticFeedbackConstants
+import android.view.MotionEvent
+import android.view.ViewConfiguration
+import android.view.animation.DecelerateInterpolator
+import eu.kanade.tachiyomi.ui.reader.viewer.GestureDetectorWithLongTap
+
+/**
+ * Implementation of a [RecyclerView] used by the webtoon reader.
+ */
+open class WebtoonRecyclerView @JvmOverloads constructor(
+        context: Context,
+        attrs: AttributeSet? = null,
+        defStyle: Int = 0
+) : RecyclerView(context, attrs, defStyle) {
+
+    private var isZooming = false
+    private var atLastPosition = false
+    private var atFirstPosition = false
+    private var halfWidth = 0
+    private var halfHeight = 0
+    private var firstVisibleItemPosition = 0
+    private var lastVisibleItemPosition = 0
+    private var currentScale = DEFAULT_RATE
+
+    private val listener = GestureListener()
+    private val detector = Detector()
+
+    var tapListener: ((MotionEvent) -> Unit)? = null
+    var longTapListener: ((MotionEvent) -> Unit)? = null
+
+    override fun onMeasure(widthSpec: Int, heightSpec: Int) {
+        halfWidth = MeasureSpec.getSize(widthSpec) / 2
+        halfHeight = MeasureSpec.getSize(heightSpec) / 2
+        super.onMeasure(widthSpec, heightSpec)
+    }
+
+    override fun onTouchEvent(e: MotionEvent): Boolean {
+        detector.onTouchEvent(e)
+        return super.onTouchEvent(e)
+    }
+
+    override fun onScrolled(dx: Int, dy: Int) {
+        super.onScrolled(dx, dy)
+        val layoutManager = layoutManager
+        lastVisibleItemPosition =
+                (layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
+        firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
+    }
+
+    @TargetApi(Build.VERSION_CODES.KITKAT)
+    override fun onScrollStateChanged(state: Int) {
+        super.onScrollStateChanged(state)
+        val layoutManager = layoutManager
+        val visibleItemCount = layoutManager.childCount
+        val totalItemCount = layoutManager.itemCount
+        atLastPosition = visibleItemCount > 0 && lastVisibleItemPosition == totalItemCount - 1
+        atFirstPosition = firstVisibleItemPosition == 0
+    }
+
+    private fun getPositionX(positionX: Float): Float {
+        val maxPositionX = halfWidth * (currentScale - 1)
+        return positionX.coerceIn(-maxPositionX, maxPositionX)
+    }
+
+    private fun getPositionY(positionY: Float): Float {
+        val maxPositionY = halfHeight * (currentScale - 1)
+        return positionY.coerceIn(-maxPositionY, maxPositionY)
+    }
+
+    private fun zoom(
+            fromRate: Float,
+            toRate: Float,
+            fromX: Float,
+            toX: Float,
+            fromY: Float,
+            toY: Float
+    ) {
+        isZooming = true
+        val animatorSet = AnimatorSet()
+        val translationXAnimator = ValueAnimator.ofFloat(fromX, toX)
+        translationXAnimator.addUpdateListener { animation -> x = animation.animatedValue as Float }
+
+        val translationYAnimator = ValueAnimator.ofFloat(fromY, toY)
+        translationYAnimator.addUpdateListener { animation -> y = animation.animatedValue as Float }
+
+        val scaleAnimator = ValueAnimator.ofFloat(fromRate, toRate)
+        scaleAnimator.addUpdateListener { animation ->
+            setScaleRate(animation.animatedValue as Float)
+        }
+        animatorSet.playTogether(translationXAnimator, translationYAnimator, scaleAnimator)
+        animatorSet.duration = ANIMATOR_DURATION_TIME.toLong()
+        animatorSet.interpolator = DecelerateInterpolator()
+        animatorSet.start()
+        animatorSet.addListener(object : Animator.AnimatorListener {
+            override fun onAnimationStart(animation: Animator) {
+
+            }
+
+            override fun onAnimationEnd(animation: Animator) {
+                isZooming = false
+                currentScale = toRate
+            }
+
+            override fun onAnimationCancel(animation: Animator) {
+
+            }
+
+            override fun onAnimationRepeat(animation: Animator) {
+
+            }
+        })
+    }
+
+    fun zoomFling(velocityX: Int, velocityY: Int): Boolean {
+        if (currentScale <= 1f) return false
+
+        val distanceTimeFactor = 0.4f
+        var newX: Float? = null
+        var newY: Float? = null
+
+        if (velocityX != 0) {
+            val dx = (distanceTimeFactor * velocityX / 2)
+            newX = getPositionX(x + dx)
+        }
+        if (velocityY != 0 && (atFirstPosition || atLastPosition)) {
+            val dy = (distanceTimeFactor * velocityY / 2)
+            newY = getPositionY(y + dy)
+        }
+
+        animate()
+            .apply {
+                newX?.let { x(it) }
+                newY?.let { y(it) }
+            }
+            .setInterpolator(DecelerateInterpolator())
+            .setDuration(400)
+            .start()
+
+        return true
+    }
+
+    private fun zoomScrollBy(dx: Int, dy: Int) {
+        if (dx != 0) {
+            x = getPositionX(x + dx)
+        }
+        if (dy != 0) {
+            y = getPositionY(y + dy)
+        }
+    }
+
+    private fun setScaleRate(rate: Float) {
+        scaleX = rate
+        scaleY = rate
+    }
+
+    fun onScale(scaleFactor: Float) {
+        currentScale *= scaleFactor
+        currentScale = currentScale.coerceIn(
+                DEFAULT_RATE,
+                MAX_SCALE_RATE)
+
+        setScaleRate(currentScale)
+
+        if (currentScale != DEFAULT_RATE) {
+            x = getPositionX(x)
+            y = getPositionY(y)
+        } else {
+            x = 0f
+            y = 0f
+        }
+    }
+
+    fun onScaleBegin() {
+        if (detector.isDoubleTapping) {
+            detector.isQuickScaling = true
+        }
+    }
+
+    fun onScaleEnd() {
+        if (scaleX < DEFAULT_RATE) {
+            zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f)
+        }
+    }
+
+    inner class GestureListener : GestureDetectorWithLongTap.Listener() {
+
+        override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
+            tapListener?.invoke(ev)
+            return false
+        }
+
+        override fun onDoubleTap(ev: MotionEvent): Boolean {
+            detector.isDoubleTapping = true
+            return false
+        }
+
+        fun onDoubleTapConfirmed(ev: MotionEvent) {
+            if (!isZooming) {
+                if (scaleX != DEFAULT_RATE) {
+                    zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f)
+                } else {
+                    val toScale = 2f
+                    val toX = (halfWidth - ev.x) * (toScale - 1)
+                    val toY = (halfHeight - ev.y) * (toScale - 1)
+                    zoom(DEFAULT_RATE, toScale, 0f, toX, 0f, toY)
+                }
+            }
+        }
+
+        override fun onLongTapConfirmed(ev: MotionEvent) {
+            val listener = longTapListener
+            if (listener != null) {
+                listener.invoke(ev)
+                performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
+            }
+        }
+
+    }
+
+    inner class Detector : GestureDetectorWithLongTap(context, listener) {
+
+        private var scrollPointerId = 0
+        private var downX = 0
+        private var downY = 0
+        private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
+        private var isZoomDragging = false
+        var isDoubleTapping = false
+        var isQuickScaling = false
+
+        override fun onTouchEvent(ev: MotionEvent): Boolean {
+            val action = ev.actionMasked
+            val actionIndex = ev.actionIndex
+
+            when (action) {
+                MotionEvent.ACTION_DOWN -> {
+                    scrollPointerId = ev.getPointerId(0)
+                    downX = (ev.x + 0.5f).toInt()
+                    downY = (ev.y + 0.5f).toInt()
+                }
+                MotionEvent.ACTION_POINTER_DOWN -> {
+                    scrollPointerId = ev.getPointerId(actionIndex)
+                    downX = (ev.getX(actionIndex) + 0.5f).toInt()
+                    downY = (ev.getY(actionIndex) + 0.5f).toInt()
+                }
+                MotionEvent.ACTION_MOVE -> {
+                    if (isDoubleTapping && isQuickScaling) {
+                        return true
+                    }
+
+                    val index = ev.findPointerIndex(scrollPointerId)
+                    if (index < 0) {
+                        return false
+                    }
+
+                    val x = (ev.getX(index) + 0.5f).toInt()
+                    val y = (ev.getY(index) + 0.5f).toInt()
+                    var dx = x - downX
+                    var dy = if (atFirstPosition || atLastPosition) y - downY else 0
+
+                    if (!isZoomDragging && currentScale > 1f) {
+                        var startScroll = false
+
+                        if (Math.abs(dx) > touchSlop) {
+                            if (dx < 0) {
+                                dx += touchSlop
+                            } else {
+                                dx -= touchSlop
+                            }
+                            startScroll = true
+                        }
+                        if (Math.abs(dy) > touchSlop) {
+                            if (dy < 0) {
+                                dy += touchSlop
+                            } else {
+                                dy -= touchSlop
+                            }
+                            startScroll = true
+                        }
+
+                        if (startScroll) {
+                            isZoomDragging = true
+                        }
+                    }
+
+                    if (isZoomDragging) {
+                        zoomScrollBy(dx, dy)
+                    }
+                }
+                MotionEvent.ACTION_UP -> {
+                    if (isDoubleTapping && !isQuickScaling) {
+                        listener.onDoubleTapConfirmed(ev)
+                    }
+                    isZoomDragging = false
+                    isDoubleTapping = false
+                    isQuickScaling = false
+                }
+                MotionEvent.ACTION_CANCEL -> {
+                    isZoomDragging = false
+                    isDoubleTapping = false
+                    isQuickScaling = false
+                }
+            }
+            return super.onTouchEvent(ev)
+        }
+
+    }
+
+    private companion object {
+        const val ANIMATOR_DURATION_TIME = 200
+        const val DEFAULT_RATE = 1f
+        const val MAX_SCALE_RATE = 3f
+    }
+
+}

+ 21 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonSubsamplingImageView.kt

@@ -0,0 +1,21 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.MotionEvent
+import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
+
+/**
+ * Implementation of subsampling scale image view that ignores all touch events, because the
+ * webtoon viewer handles all the gestures.
+ */
+class WebtoonSubsamplingImageView @JvmOverloads constructor(
+        context: Context,
+        attrs: AttributeSet? = null
+) : SubsamplingScaleImageView(context, attrs) {
+
+    override fun onTouchEvent(event: MotionEvent): Boolean {
+        return false
+    }
+
+}

+ 195 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt

@@ -0,0 +1,195 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+import android.support.v7.widget.AppCompatButton
+import android.support.v7.widget.AppCompatTextView
+import android.view.Gravity
+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.util.visibleIf
+import rx.Subscription
+import rx.android.schedulers.AndroidSchedulers
+
+/**
+ * Holder of the webtoon viewer that contains a chapter transition.
+ */
+class WebtoonTransitionHolder(
+        val layout: LinearLayout,
+        viewer: WebtoonViewer
+) : WebtoonBaseHolder(layout, viewer) {
+
+    /**
+     * 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)
+
+    /**
+     * View container of the current status of the transition page. Child views will be added
+     * dynamically.
+     */
+    private var pagesContainer = LinearLayout(context).apply {
+        orientation = LinearLayout.VERTICAL
+        gravity = Gravity.CENTER
+    }
+
+    init {
+        layout.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+        layout.orientation = LinearLayout.VERTICAL
+        layout.gravity = Gravity.CENTER
+
+        val paddingVertical = 48.dpToPx
+        val paddingHorizontal = 32.dpToPx
+        layout.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical)
+
+        val childMargins = 16.dpToPx
+        val childParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
+            setMargins(0, childMargins, 0, childMargins)
+        }
+
+        layout.addView(textView, childParams)
+        layout.addView(pagesContainer, childParams)
+    }
+
+    /**
+     * Binds the given [transition] with this view holder, subscribing to its state.
+     */
+    fun bind(transition: ChapterTransition) {
+        when (transition) {
+            is ChapterTransition.Prev -> bindPrevChapterTransition(transition)
+            is ChapterTransition.Next -> bindNextChapterTransition(transition)
+        }
+    }
+
+    /**
+     * Called when the view is recycled and being added to the view pool.
+     */
+    override fun recycle() {
+        unsubscribeStatus()
+    }
+
+    /**
+     * Binds a next chapter transition on this view and subscribes to the load status.
+     */
+    private fun bindNextChapterTransition(transition: ChapterTransition.Next) {
+        val nextChapter = transition.to
+
+        textView.text = if (nextChapter != null) {
+            context.getString(R.string.transition_finished, transition.from.chapter.name) + "\n\n" +
+            context.getString(R.string.transition_next, nextChapter.chapter.name)
+        } else {
+            context.getString(R.string.transition_no_next)
+        }
+
+        if (nextChapter != null) {
+            observeStatus(nextChapter, transition)
+        }
+    }
+
+    /**
+     * Binds a previous chapter transition on this view and subscribes to the page load status.
+     */
+    private fun bindPrevChapterTransition(transition: ChapterTransition.Prev) {
+        val prevChapter = transition.to
+
+        textView.text = if (prevChapter != null) {
+            context.getString(R.string.transition_current, transition.from.chapter.name) + "\n\n" +
+            context.getString(R.string.transition_previous, prevChapter.chapter.name)
+        } else {
+            context.getString(R.string.transition_no_previous)
+        }
+
+        if (prevChapter != null) {
+            observeStatus(prevChapter, transition)
+        }
+    }
+
+    /**
+     * 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, transition: ChapterTransition) {
+        unsubscribeStatus()
+
+        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, transition)
+                    is ReaderChapter.State.Loaded -> setLoaded()
+                }
+                pagesContainer.visibleIf { pagesContainer.childCount > 0 }
+            }
+
+        addSubscription(statusSubscription)
+    }
+
+    /**
+     * Unsubscribes from the status subscription.
+     */
+    private fun unsubscribeStatus() {
+        removeSubscription(statusSubscription)
+        statusSubscription = null
+    }
+
+    /**
+     * 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, transition: ChapterTransition) {
+        val textView = AppCompatTextView(context).apply {
+            wrapContent()
+            text = context.getString(R.string.transition_pages_error, error.message)
+        }
+
+        val retryBtn = AppCompatButton(context).apply {
+            wrapContent()
+            setText(R.string.action_retry)
+            setOnClickListener {
+                if (transition is ChapterTransition.Next) {
+                    viewer.activity.requestPreloadNextChapter()
+                } else {
+                    viewer.activity.requestPreloadPreviousChapter()
+                }
+            }
+        }
+
+        pagesContainer.addView(textView)
+        pagesContainer.addView(retryBtn)
+    }
+
+}

+ 240 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt

@@ -0,0 +1,240 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+import android.support.v7.widget.RecyclerView
+import android.support.v7.widget.WebtoonLayoutManager
+import android.view.KeyEvent
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+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 rx.subscriptions.CompositeSubscription
+import timber.log.Timber
+
+/**
+ * Implementation of a [BaseViewer] to display pages with a [RecyclerView].
+ */
+class WebtoonViewer(val activity: ReaderActivity) : BaseViewer {
+
+    /**
+     * Recycler view used by this viewer.
+     */
+    val recycler = WebtoonRecyclerView(activity)
+
+    /**
+     * Frame containing the recycler view.
+     */
+    private val frame = WebtoonFrame(activity)
+
+    /**
+     * Layout manager of the recycler view.
+     */
+    private val layoutManager = WebtoonLayoutManager(activity)
+
+    /**
+     * Adapter of the recycler view.
+     */
+    private val adapter = WebtoonAdapter(this)
+
+    /**
+     * Distance to scroll when the user taps on one side of the recycler view.
+     */
+    private var scrollDistance = activity.resources.displayMetrics.heightPixels * 3 / 4
+
+    /**
+     * Currently active item. It can be a chapter page or a chapter transition.
+     */
+    private var currentPage: Any? = null
+
+    /**
+     * Configuration used by this viewer, like allow taps, or crop image borders.
+     */
+    val config = WebtoonConfig()
+
+    /**
+     * Subscriptions to keep while this viewer is used.
+     */
+    val subscriptions = CompositeSubscription()
+
+    init {
+        recycler.visibility = View.GONE // Don't let the recycler layout yet
+        recycler.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
+        recycler.itemAnimator = null
+        recycler.layoutManager = layoutManager
+        recycler.adapter = adapter
+        recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+            override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) {
+                val index = layoutManager.findLastEndVisibleItemPosition()
+                val item = adapter.items.getOrNull(index)
+                if (item != null && currentPage != item) {
+                    currentPage = item
+                    when (item) {
+                        is ReaderPage -> onPageSelected(item)
+                        is ChapterTransition -> onTransitionSelected(item)
+                    }
+                }
+
+                if (dy < 0) {
+                    val firstIndex = layoutManager.findFirstVisibleItemPosition()
+                    val firstItem = adapter.items.getOrNull(firstIndex)
+                    if (firstItem is ChapterTransition.Prev) {
+                        activity.requestPreloadPreviousChapter()
+                    }
+                }
+            }
+        })
+        recycler.tapListener = { event ->
+            val positionX = event.rawX
+            when {
+                positionX < recycler.width * 0.33 -> if (config.tappingEnabled) scrollUp()
+                positionX > recycler.width * 0.66 -> if (config.tappingEnabled) scrollDown()
+                else -> activity.toggleMenu()
+            }
+        }
+        recycler.longTapListener = { event ->
+            val child = recycler.findChildViewUnder(event.x, event.y)
+            val position = recycler.getChildAdapterPosition(child)
+            val item = adapter.items.getOrNull(position)
+            if (item is ReaderPage) {
+                activity.onPageLongTap(item)
+            }
+        }
+
+        frame.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
+        frame.addView(recycler)
+    }
+
+    /**
+     * Returns the view this viewer uses.
+     */
+    override fun getView(): View {
+        return frame
+    }
+
+    /**
+     * Destroys this viewer. Called when leaving the reader or swapping viewers.
+     */
+    override fun destroy() {
+        super.destroy()
+        config.unsubscribe()
+        subscriptions.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) {
+        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")
+            activity.requestPreloadNextChapter()
+        }
+    }
+
+    /**
+     * 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")
+        if (transition is ChapterTransition.Prev) {
+            Timber.d("Request preload previous chapter because we're on the transition")
+            activity.requestPreloadPreviousChapter()
+        }
+    }
+
+    /**
+     * Tells this viewer to set the given [chapters] as active.
+     */
+    override fun setChapters(chapters: ViewerChapters) {
+        Timber.d("setChapters")
+        adapter.setChapters(chapters)
+
+        if (recycler.visibility == View.GONE) {
+            Timber.d("Recycler first layout")
+            val pages = chapters.currChapter.pages ?: return
+            moveToPage(pages[chapters.currChapter.requestedPage])
+            recycler.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) {
+            recycler.scrollToPosition(position)
+        } else {
+            Timber.d("Page $page not found in adapter")
+        }
+    }
+
+    /**
+     * Scrolls up by [scrollDistance].
+     */
+    private fun scrollUp() {
+        recycler.smoothScrollBy(0, -scrollDistance)
+    }
+
+    /**
+     * Scrolls down by [scrollDistance].
+     */
+    private fun scrollDown() {
+        recycler.smoothScrollBy(0, scrollDistance)
+    }
+
+    /**
+     * Called from the containing activity when a key [event] is received. It should return true
+     * if the event was handled, false otherwise.
+     */
+    override fun handleKeyEvent(event: KeyEvent): Boolean {
+        val isUp = event.action == KeyEvent.ACTION_UP
+
+        when (event.keyCode) {
+            KeyEvent.KEYCODE_VOLUME_DOWN -> {
+                if (activity.menuVisible) {
+                    return false
+                } else if (config.volumeKeysEnabled && isUp) {
+                    if (!config.volumeKeysInverted) scrollDown() else scrollUp()
+                }
+            }
+            KeyEvent.KEYCODE_VOLUME_UP -> {
+                if (activity.menuVisible) {
+                    return false
+                } else if (config.volumeKeysEnabled && isUp) {
+                    if (!config.volumeKeysInverted) scrollUp() else scrollDown()
+                }
+            }
+            KeyEvent.KEYCODE_MENU -> if (isUp) activity.toggleMenu()
+
+            KeyEvent.KEYCODE_DPAD_RIGHT,
+            KeyEvent.KEYCODE_DPAD_UP,
+            KeyEvent.KEYCODE_PAGE_UP -> if (isUp) scrollUp()
+
+            KeyEvent.KEYCODE_DPAD_LEFT,
+            KeyEvent.KEYCODE_DPAD_DOWN,
+            KeyEvent.KEYCODE_PAGE_DOWN -> if (isUp) scrollDown()
+            else -> return false
+        }
+        return true
+    }
+
+    /**
+     * Called from the containing activity when a generic motion [event] is received. It should
+     * return true if the event was handled, false otherwise.
+     */
+    override fun handleGenericMotionEvent(event: MotionEvent): Boolean {
+        return false
+    }
+
+}

+ 17 - 11
app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt

@@ -165,9 +165,8 @@ class RecentChaptersPresenter(
      * @param chapters list of chapters
      */
     fun deleteChapters(chapters: List<RecentChapterItem>) {
-        Observable.from(chapters)
-                .doOnNext { deleteChapter(it) }
-                .toList()
+        Observable.just(chapters)
+                .doOnNext { deleteChaptersInternal(it) }
                 .subscribeOn(Schedulers.io())
                 .observeOn(AndroidSchedulers.mainThread())
                 .subscribeFirst({ view, _ ->
@@ -184,16 +183,23 @@ class RecentChaptersPresenter(
     }
 
     /**
-     * Delete selected chapter
+     * Delete selected chapters
      *
-     * @param item chapter that is selected
+     * @param items chapters selected
      */
-    private fun deleteChapter(item: RecentChapterItem) {
-        val source = sourceManager.get(item.manga.source) ?: return
-        downloadManager.queue.remove(item.chapter)
-        downloadManager.deleteChapter(item.chapter, item.manga, source)
-        item.status = Download.NOT_DOWNLOADED
-        item.download = null
+    private fun deleteChaptersInternal(chapterItems: List<RecentChapterItem>) {
+        val itemsByManga = chapterItems.groupBy { it.manga.id }
+        for ((_, items) in itemsByManga) {
+            val manga = items.first().manga
+            val source = sourceManager.get(manga.source) ?: continue
+            val chapters = items.map { it.chapter }
+
+            downloadManager.deleteChapters(chapters, manga, source)
+            items.forEach {
+                it.status = Download.NOT_DOWNLOADED
+                it.download = null
+            }
+        }
     }
 
 }

+ 0 - 8
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt

@@ -54,14 +54,6 @@ class SettingsReaderController : SettingsController() {
             defaultValue = "0"
             summary = "%s"
         }
-        intListPreference {
-            key = Keys.imageDecoder
-            titleRes = R.string.pref_image_decoder
-            entries = arrayOf("Image", "Rapid", "Skia")
-            entryValues = arrayOf("0", "1", "2")
-            defaultValue = "0"
-            summary = "%s"
-        }
         intListPreference {
             key = Keys.doubleTapAnimationSpeed
             titleRes = R.string.pref_double_tap_anim_speed

+ 3 - 2
app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt

@@ -11,6 +11,7 @@ import android.content.pm.PackageManager
 import android.content.res.Resources
 import android.net.ConnectivityManager
 import android.os.PowerManager
+import android.support.annotation.AttrRes
 import android.support.annotation.StringRes
 import android.support.v4.app.NotificationCompat
 import android.support.v4.content.ContextCompat
@@ -79,7 +80,7 @@ fun Context.hasPermission(permission: String)
  *
  * @param resource the attribute.
  */
-fun Context.getResourceColor(@StringRes resource: Int): Int {
+fun Context.getResourceColor(@AttrRes resource: Int): Int {
     val typedArray = obtainStyledAttributes(intArrayOf(resource))
     val attrValue = typedArray.getColor(0, 0)
     typedArray.recycle()
@@ -161,4 +162,4 @@ fun Context.isServiceRunning(serviceClass: Class<*>): Boolean {
     @Suppress("DEPRECATION")
     return manager.getRunningServices(Integer.MAX_VALUE)
             .any { className == it.service.className }
-}
+}

+ 0 - 38
app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt

@@ -8,47 +8,9 @@ import android.os.Environment
 import android.support.v4.content.ContextCompat
 import android.support.v4.os.EnvironmentCompat
 import java.io.File
-import java.io.InputStream
-import java.net.URLConnection
 
 object DiskUtil {
 
-    fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
-        val contentType = try {
-            URLConnection.guessContentTypeFromName(name)
-        } catch (e: Exception) {
-            null
-        } ?: openStream?.let { findImageMime(it) }
-
-        return contentType?.startsWith("image/") ?: false
-    }
-
-    fun findImageMime(openStream: () -> InputStream): String? {
-        try {
-            openStream().buffered().use {
-                val bytes = ByteArray(8)
-                it.mark(bytes.size)
-                val length = it.read(bytes, 0, bytes.size)
-                it.reset()
-                if (length == -1)
-                    return null
-                if (bytes[0] == 'G'.toByte() && bytes[1] == 'I'.toByte() && bytes[2] == 'F'.toByte() && bytes[3] == '8'.toByte()) {
-                    return "image/gif"
-                } else if (bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && bytes[2] == 0x4E.toByte()
-                        && bytes[3] == 0x47.toByte() && bytes[4] == 0x0D.toByte() && bytes[5] == 0x0A.toByte()
-                        && bytes[6] == 0x1A.toByte() && bytes[7] == 0x0A.toByte()) {
-                    return "image/png"
-                } else if (bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte()) {
-                    return "image/jpeg"
-                } else if (bytes[0] == 'W'.toByte() && bytes[1] == 'E'.toByte() && bytes[2] == 'B'.toByte() && bytes[3] == 'P'.toByte()) {
-                    return "image/webp"
-                }
-            }
-        } catch(e: Exception) {
-        }
-        return null
-    }
-
     fun hashKeyForDisk(key: String): String {
         return Hash.md5(key)
     }

+ 117 - 0
app/src/main/java/eu/kanade/tachiyomi/util/EpubFile.kt

@@ -0,0 +1,117 @@
+package eu.kanade.tachiyomi.util
+
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Document
+import java.io.Closeable
+import java.io.File
+import java.io.InputStream
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+
+/**
+ * Wrapper over ZipFile to load files in epub format.
+ */
+class EpubFile(file: File) : Closeable {
+
+    /**
+     * Zip file of this epub.
+     */
+    private val zip = ZipFile(file)
+
+    /**
+     * Closes the underlying zip file.
+     */
+    override fun close() {
+        zip.close()
+    }
+
+    /**
+     * Returns an input stream for reading the contents of the specified zip file entry.
+     */
+    fun getInputStream(entry: ZipEntry): InputStream {
+        return zip.getInputStream(entry)
+    }
+
+    /**
+     * Returns the zip file entry for the specified name, or null if not found.
+     */
+    fun getEntry(name: String): ZipEntry? {
+        return zip.getEntry(name)
+    }
+
+    /**
+     * Returns the path of all the images found in the epub file.
+     */
+    fun getImagesFromPages(): List<String> {
+        val allEntries = zip.entries().toList()
+        val ref = getPackageHref()
+        val doc = getPackageDocument(ref)
+        val pages = getPagesFromDocument(doc)
+        val hrefs = getHrefMap(ref, allEntries.map { it.name })
+        return getImagesFromPages(pages, hrefs)
+    }
+
+    /**
+     * Returns the path to the package document.
+     */
+    private fun getPackageHref(): 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(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(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
+            }
+        }
+    }
+
+}

+ 78 - 0
app/src/main/java/eu/kanade/tachiyomi/util/ImageUtil.kt

@@ -0,0 +1,78 @@
+package eu.kanade.tachiyomi.util
+
+import java.io.InputStream
+import java.net.URLConnection
+
+object ImageUtil {
+
+    fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
+        try {
+            val guessedMime = URLConnection.guessContentTypeFromName(name)
+            if (guessedMime.startsWith("image/")) {
+                return true
+            }
+        } catch (e: Exception) {
+            /* Ignore error */
+        }
+
+        return openStream?.let { findImageType(it) } != null
+    }
+
+    fun findImageType(openStream: () -> InputStream): ImageType? {
+        return openStream().use { findImageType(it) }
+    }
+
+    fun findImageType(stream: InputStream): ImageType? {
+        try {
+            val bytes = ByteArray(8)
+
+            val length = if (stream.markSupported()) {
+                stream.mark(bytes.size)
+                stream.read(bytes, 0, bytes.size).also { stream.reset() }
+            } else {
+                stream.read(bytes, 0, bytes.size)
+            }
+
+            if (length == -1)
+                return null
+
+            if (bytes.compareWith(charByteArrayOf(0xFF, 0xD8, 0xFF))) {
+                return ImageType.JPG
+            }
+            if (bytes.compareWith(charByteArrayOf(0x89, 0x50, 0x4E, 0x47))) {
+                return ImageType.PNG
+            }
+            if (bytes.compareWith("GIF8".toByteArray())) {
+                return ImageType.GIF
+            }
+            if (bytes.compareWith("RIFF".toByteArray())) {
+                return ImageType.WEBP
+            }
+        } catch(e: Exception) {
+        }
+        return null
+    }
+
+    private fun ByteArray.compareWith(magic: ByteArray): Boolean {
+        for (i in 0 until magic.size) {
+            if (this[i] != magic[i]) return false
+        }
+        return true
+    }
+
+    private fun charByteArrayOf(vararg bytes: Int): ByteArray {
+        return ByteArray(bytes.size).apply {
+            for (i in 0 until bytes.size) {
+                set(i, bytes[i].toByte())
+            }
+        }
+    }
+
+    enum class ImageType(val mime: String, val extension: String) {
+        JPG("image/jpeg", "jpg"),
+        PNG("image/png", "png"),
+        GIF("image/gif", "gif"),
+        WEBP("image/webp", "webp")
+    }
+
+}

+ 0 - 73
app/src/main/java/eu/kanade/tachiyomi/util/RarContentProvider.kt

@@ -1,73 +0,0 @@
-package eu.kanade.tachiyomi.util
-
-import android.content.ContentProvider
-import android.content.ContentValues
-import android.content.res.AssetFileDescriptor
-import android.database.Cursor
-import android.net.Uri
-import android.os.ParcelFileDescriptor
-import eu.kanade.tachiyomi.BuildConfig
-import junrar.Archive
-import java.io.File
-import java.io.IOException
-import java.net.URLConnection
-import java.util.concurrent.Executors
-
-class RarContentProvider : ContentProvider() {
-
-    private val pool by lazy { Executors.newCachedThreadPool() }
-
-    companion object {
-        const val PROVIDER = "${BuildConfig.APPLICATION_ID}.rar-provider"
-    }
-
-    override fun onCreate(): Boolean {
-        return true
-    }
-
-    override fun getType(uri: Uri): String? {
-        return URLConnection.guessContentTypeFromName(uri.toString())
-    }
-
-    override fun openAssetFile(uri: Uri, mode: String): AssetFileDescriptor? {
-        try {
-            val pipe = ParcelFileDescriptor.createPipe()
-            pool.execute {
-                try {
-                    val (rar, file) = uri.toString()
-                            .substringAfter("content://$PROVIDER")
-                            .split("!-/", limit = 2)
-
-                    Archive(File(rar)).use { archive ->
-                        val fileHeader = archive.fileHeaders.first { it.fileNameString == file }
-
-                        ParcelFileDescriptor.AutoCloseOutputStream(pipe[1]).use { output ->
-                            archive.extractFile(fileHeader, output)
-                        }
-                    }
-                } catch (e: Exception) {
-                    // Ignore
-                }
-            }
-            return AssetFileDescriptor(pipe[0], 0, -1)
-        } catch (e: IOException) {
-            return null
-        }
-    }
-
-    override fun query(p0: Uri?, p1: Array<out String>?, p2: String?, p3: Array<out String>?, p4: String?): Cursor? {
-        return null
-    }
-
-    override fun insert(p0: Uri?, p1: ContentValues?): Uri {
-        throw UnsupportedOperationException("not implemented")
-    }
-
-    override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array<out String>?): Int {
-        throw UnsupportedOperationException("not implemented")
-    }
-
-    override fun delete(p0: Uri?, p1: String?, p2: Array<out String>?): Int {
-        throw UnsupportedOperationException("not implemented")
-    }
-}

+ 5 - 1
app/src/main/java/eu/kanade/tachiyomi/util/RxExtensions.kt

@@ -10,4 +10,8 @@ operator fun CompositeSubscription.plusAssign(subscription: Subscription) = add(
 
 fun <T, U, R> Observable<T>.combineLatest(o2: Observable<U>, combineFn: (T, U) -> R): Observable<R> {
     return Observable.combineLatest(this, o2, combineFn)
-}
+}
+
+fun Subscription.addTo(subscriptions: CompositeSubscription) {
+    subscriptions.add(this)
+}

+ 0 - 69
app/src/main/java/eu/kanade/tachiyomi/util/ZipContentProvider.kt

@@ -1,69 +0,0 @@
-package eu.kanade.tachiyomi.util
-
-import android.content.ContentProvider
-import android.content.ContentValues
-import android.content.res.AssetFileDescriptor
-import android.database.Cursor
-import android.net.Uri
-import android.os.ParcelFileDescriptor
-import eu.kanade.tachiyomi.BuildConfig
-import java.io.IOException
-import java.net.URL
-import java.net.URLConnection
-import java.util.concurrent.Executors
-
-class ZipContentProvider : ContentProvider() {
-
-    private val pool by lazy { Executors.newCachedThreadPool() }
-
-    companion object {
-        const val PROVIDER = "${BuildConfig.APPLICATION_ID}.zip-provider"
-    }
-
-    override fun onCreate(): Boolean {
-        return true
-    }
-
-    override fun getType(uri: Uri): String? {
-        return URLConnection.guessContentTypeFromName(uri.toString())
-    }
-
-    override fun openAssetFile(uri: Uri, mode: String): AssetFileDescriptor? {
-        try {
-            val url = "jar:file://" + uri.toString().substringAfter("content://$PROVIDER")
-            val input = URL(url).openStream()
-            val pipe = ParcelFileDescriptor.createPipe()
-            pool.execute {
-                try {
-                    val output = ParcelFileDescriptor.AutoCloseOutputStream(pipe[1])
-                    input.use {
-                        output.use {
-                            input.copyTo(output)
-                        }
-                    }
-                } catch (e: IOException) {
-                    // Ignore
-                }
-            }
-            return AssetFileDescriptor(pipe[0], 0, -1)
-        } catch (e: IOException) {
-            return null
-        }
-    }
-
-    override fun query(p0: Uri?, p1: Array<out String>?, p2: String?, p3: Array<out String>?, p4: String?): Cursor? {
-        return null
-    }
-
-    override fun insert(p0: Uri?, p1: ContentValues?): Uri {
-        throw UnsupportedOperationException("not implemented")
-    }
-
-    override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array<out String>?): Int {
-        throw UnsupportedOperationException("not implemented")
-    }
-
-    override fun delete(p0: Uri?, p1: String?, p2: Array<out String>?): Int {
-        throw UnsupportedOperationException("not implemented")
-    }
-}

+ 5 - 1
app/src/main/java/eu/kanade/tachiyomi/widget/ViewPagerAdapter.kt

@@ -27,4 +27,8 @@ abstract class ViewPagerAdapter : PagerAdapter() {
         return view === obj
     }
 
-}
+    interface PositionableView {
+        val item: Any
+    }
+
+}

+ 9 - 0
app/src/main/res/drawable/ic_image_black_24dp.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z"/>
+</vector>

+ 50 - 0
app/src/main/res/layout-land/reader_color_filter_sheet.xml

@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+<android.support.constraint.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="horizontal"
+    android:baselineAligned="false"
+    android:background="?android:colorBackground">
+
+    <FrameLayout
+        android:id="@+id/frame"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toLeftOf="@id/scroll"
+        app:layout_constraintTop_toTopOf="@id/scroll"
+        app:layout_constraintBottom_toBottomOf="@id/scroll">
+
+        <android.support.v7.widget.AppCompatImageView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:scaleType="centerCrop"
+            android:src="@drawable/filter_mock" />
+
+        <View
+            android:id="@+id/brightness_overlay"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:visibility="gone" />
+
+        <View
+            android:id="@+id/color_overlay"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:visibility="gone" />
+    </FrameLayout>
+
+    <android.support.v4.widget.NestedScrollView
+        android:id="@+id/scroll"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        app:layout_constraintLeft_toRightOf="@id/frame"
+        app:layout_constraintRight_toRightOf="parent">
+
+        <include layout="@layout/reader_color_filter"/>
+
+    </android.support.v4.widget.NestedScrollView>
+
+</android.support.constraint.ConstraintLayout>

+ 22 - 18
app/src/main/res/layout/reader_activity.xml

@@ -11,17 +11,18 @@
         android:layout_height="match_parent">
 
         <FrameLayout
-            android:id="@+id/reader"
+            android:id="@+id/viewer_container"
             android:layout_width="match_parent"
             android:layout_height="match_parent">
         </FrameLayout>
 
         <ProgressBar
             android:id="@+id/please_wait"
-            style="?android:attr/progressBarStyleLarge"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center"/>
+            android:layout_width="56dp"
+            android:layout_height="56dp"
+            android:layout_gravity="center"
+            android:visibility="gone"
+            tools:visibility="visible"/>
 
         <eu.kanade.tachiyomi.ui.reader.PageIndicatorTextView
             android:id="@+id/page_number"
@@ -39,6 +40,7 @@
         android:layout_width="match_parent"
         android:layout_height="match_parent"
         android:fitsSystemWindows="true"
+        android:theme="?attr/actionBarTheme"
         android:visibility="invisible"
         tools:visibility="visible">
 
@@ -47,8 +49,7 @@
             android:layout_width="match_parent"
             android:layout_height="?attr/actionBarSize"
             android:background="?colorPrimary"
-            android:elevation="4dp"
-            android:theme="?attr/actionBarTheme"/>
+            android:elevation="4dp" />
 
         <LinearLayout
             android:id="@+id/reader_menu_bottom"
@@ -58,7 +59,6 @@
             android:gravity="center"
             android:background="?colorPrimary"
             android:orientation="horizontal"
-            android:focusable="false"
             android:descendantFocusability="blocksDescendants">
 
             <ImageButton
@@ -66,35 +66,39 @@
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
                 android:padding="@dimen/material_layout_keylines_screen_edge_margin"
-                android:background="?android:selectableItemBackground"
+                android:background="?selectableItemBackgroundBorderless"
                 app:srcCompat="@drawable/ic_skip_previous_white_24dp"/>
 
             <TextView
                 android:id="@+id/left_page_text"
                 android:layout_width="32dp"
-                android:layout_height="wrap_content"
+                android:layout_height="match_parent"
                 android:gravity="center"
-                android:textSize="15sp"/>
+                android:textSize="15sp"
+                android:clickable="true"
+                tools:text="1"/>
 
-            <SeekBar
+            <eu.kanade.tachiyomi.ui.reader.ReaderSeekBar
                 android:id="@+id/page_seekbar"
                 android:layout_width="0dp"
-                android:layout_height="wrap_content"
-                android:layout_weight="1"/>
+                android:layout_height="match_parent"
+                android:layout_weight="1" />
 
             <TextView
                 android:id="@+id/right_page_text"
                 android:layout_width="32dp"
-                android:layout_height="wrap_content"
+                android:layout_height="match_parent"
                 android:gravity="center"
-                android:textSize="15sp"/>
+                android:textSize="15sp"
+                android:clickable="true"
+                tools:text="15"/>
 
             <ImageButton
                 android:id="@+id/right_chapter"
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
                 android:padding="@dimen/material_layout_keylines_screen_edge_margin"
-                android:background="?android:selectableItemBackground"
+                android:background="?selectableItemBackgroundBorderless"
                 app:srcCompat="@drawable/ic_skip_next_white_24dp"/>
 
         </LinearLayout>
@@ -113,4 +117,4 @@
         android:layout_height="match_parent"
         android:visibility="gone"/>
 
-</FrameLayout>
+</FrameLayout>

+ 205 - 0
app/src/main/res/layout/reader_color_filter.xml

@@ -0,0 +1,205 @@
+<?xml version="1.0" encoding="utf-8"?>
+<android.support.constraint.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:padding="16dp">
+
+    <!-- Color filter -->
+
+    <android.support.v7.widget.SwitchCompat
+        android:id="@+id/switch_color_filter"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:text="@string/pref_custom_color_filter"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintLeft_toLeftOf="parent" />
+
+    <!-- Red filter -->
+
+    <SeekBar
+        android:id="@+id/seekbar_color_filter_red"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:max="255"
+        android:layout_marginLeft="8dp"
+        android:layout_marginRight="8dp"
+        android:padding="@dimen/material_component_text_fields_floating_label_padding_between_label_and_input_text"
+        app:layout_constraintTop_toBottomOf="@id/switch_color_filter"
+        app:layout_constraintLeft_toRightOf="@id/txt_color_filter_red_symbol"
+        app:layout_constraintRight_toLeftOf="@id/txt_color_filter_red_value" />
+
+    <TextView
+        android:id="@+id/txt_color_filter_red_symbol"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/color_filter_r_value"
+        android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary"
+        app:layout_constraintTop_toTopOf="@id/seekbar_color_filter_red"
+        app:layout_constraintBottom_toBottomOf="@id/seekbar_color_filter_red"
+        app:layout_constraintLeft_toLeftOf="parent"/>
+
+    <TextView
+        android:id="@+id/txt_color_filter_red_value"
+        android:layout_width="30dp"
+        android:layout_height="wrap_content"
+        android:layout_alignParentEnd="true"
+        android:layout_alignParentRight="true"
+        android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary"
+        app:layout_constraintTop_toTopOf="@id/seekbar_color_filter_red"
+        app:layout_constraintBottom_toBottomOf="@id/seekbar_color_filter_red"
+        app:layout_constraintRight_toRightOf="parent"/>
+
+    <!-- Green filter -->
+
+    <SeekBar
+        android:id="@+id/seekbar_color_filter_green"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:max="255"
+        android:layout_marginLeft="8dp"
+        android:layout_marginRight="8dp"
+        android:padding="@dimen/material_component_text_fields_floating_label_padding_between_label_and_input_text"
+        app:layout_constraintTop_toBottomOf="@id/seekbar_color_filter_red"
+        app:layout_constraintLeft_toRightOf="@id/txt_color_filter_green_symbol"
+        app:layout_constraintRight_toLeftOf="@id/txt_color_filter_green_value" />
+
+    <TextView
+        android:id="@+id/txt_color_filter_green_symbol"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/color_filter_g_value"
+        android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary"
+        app:layout_constraintTop_toTopOf="@id/seekbar_color_filter_green"
+        app:layout_constraintBottom_toBottomOf="@id/seekbar_color_filter_green"
+        app:layout_constraintLeft_toLeftOf="parent"/>
+
+    <TextView
+        android:id="@+id/txt_color_filter_green_value"
+        android:layout_width="30dp"
+        android:layout_height="wrap_content"
+        android:layout_alignParentEnd="true"
+        android:layout_alignParentRight="true"
+        android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary"
+        app:layout_constraintTop_toTopOf="@id/seekbar_color_filter_green"
+        app:layout_constraintBottom_toBottomOf="@id/seekbar_color_filter_green"
+        app:layout_constraintRight_toRightOf="parent"/>
+
+    <!-- Blue filter -->
+
+    <SeekBar
+        android:id="@+id/seekbar_color_filter_blue"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:max="255"
+        android:layout_marginLeft="8dp"
+        android:layout_marginRight="8dp"
+        android:padding="@dimen/material_component_text_fields_floating_label_padding_between_label_and_input_text"
+        app:layout_constraintTop_toBottomOf="@id/seekbar_color_filter_green"
+        app:layout_constraintLeft_toRightOf="@id/txt_color_filter_blue_symbol"
+        app:layout_constraintRight_toLeftOf="@id/txt_color_filter_blue_value" />
+
+    <TextView
+        android:id="@+id/txt_color_filter_blue_symbol"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/color_filter_b_value"
+        android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary"
+        app:layout_constraintTop_toTopOf="@id/seekbar_color_filter_blue"
+        app:layout_constraintBottom_toBottomOf="@id/seekbar_color_filter_blue"
+        app:layout_constraintLeft_toLeftOf="parent"/>
+
+    <TextView
+        android:id="@+id/txt_color_filter_blue_value"
+        android:layout_width="30dp"
+        android:layout_height="wrap_content"
+        android:layout_alignParentEnd="true"
+        android:layout_alignParentRight="true"
+        android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary"
+        app:layout_constraintTop_toTopOf="@id/seekbar_color_filter_blue"
+        app:layout_constraintBottom_toBottomOf="@id/seekbar_color_filter_blue"
+        app:layout_constraintRight_toRightOf="parent"/>
+
+    <!-- Alpha filter -->
+
+    <SeekBar
+        android:id="@+id/seekbar_color_filter_alpha"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:max="255"
+        android:layout_marginLeft="8dp"
+        android:layout_marginRight="8dp"
+        android:padding="@dimen/material_component_text_fields_floating_label_padding_between_label_and_input_text"
+        app:layout_constraintTop_toBottomOf="@id/seekbar_color_filter_blue"
+        app:layout_constraintLeft_toRightOf="@id/txt_color_filter_alpha_symbol"
+        app:layout_constraintRight_toLeftOf="@id/txt_color_filter_alpha_value" />
+
+    <TextView
+        android:id="@+id/txt_color_filter_alpha_symbol"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/color_filter_a_value"
+        android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary"
+        app:layout_constraintTop_toTopOf="@id/seekbar_color_filter_alpha"
+        app:layout_constraintBottom_toBottomOf="@id/seekbar_color_filter_alpha"
+        app:layout_constraintLeft_toLeftOf="parent"/>
+
+    <TextView
+        android:id="@+id/txt_color_filter_alpha_value"
+        android:layout_width="30dp"
+        android:layout_height="wrap_content"
+        android:layout_alignParentEnd="true"
+        android:layout_alignParentRight="true"
+        android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary"
+        app:layout_constraintTop_toTopOf="@id/seekbar_color_filter_alpha"
+        app:layout_constraintBottom_toBottomOf="@id/seekbar_color_filter_alpha"
+        app:layout_constraintRight_toRightOf="parent"/>
+
+    <!-- Brightness -->
+
+    <android.support.v7.widget.SwitchCompat
+        android:id="@+id/custom_brightness"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:text="@string/pref_custom_brightness"
+        app:layout_constraintTop_toBottomOf="@id/seekbar_color_filter_alpha"/>
+
+    <!-- Brightness value -->
+
+    <eu.kanade.tachiyomi.widget.NegativeSeekBar
+        android:id="@+id/brightness_seekbar"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginLeft="8dp"
+        android:layout_marginRight="8dp"
+        android:padding="@dimen/material_component_text_fields_floating_label_padding_between_label_and_input_text"
+        app:layout_constraintTop_toBottomOf="@id/custom_brightness"
+        app:layout_constraintLeft_toRightOf="@id/txt_brightness_seekbar_icon"
+        app:layout_constraintRight_toLeftOf="@id/txt_brightness_seekbar_value"
+        app:max_seek="100"
+        app:min_seek="-75" />
+
+    <android.support.v7.widget.AppCompatImageView
+        android:id="@+id/txt_brightness_seekbar_icon"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary"
+        android:tint="?android:attr/textColorSecondary"
+        app:srcCompat="@drawable/ic_brightness_5_black_24dp"
+        app:layout_constraintTop_toTopOf="@id/brightness_seekbar"
+        app:layout_constraintBottom_toBottomOf="@id/brightness_seekbar"
+        app:layout_constraintLeft_toLeftOf="parent"/>
+
+    <TextView
+        android:id="@+id/txt_brightness_seekbar_value"
+        android:layout_width="30dp"
+        android:layout_height="wrap_content"
+        android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary"
+        app:layout_constraintTop_toTopOf="@id/brightness_seekbar"
+        app:layout_constraintBottom_toBottomOf="@id/brightness_seekbar"
+        app:layout_constraintRight_toRightOf="parent"/>
+
+</android.support.constraint.ConstraintLayout>

+ 39 - 0
app/src/main/res/layout/reader_color_filter_sheet.xml

@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:background="?android:colorBackground">
+
+    <FrameLayout
+        android:layout_width="match_parent"
+        android:layout_height="200dp">
+
+        <android.support.v7.widget.AppCompatImageView
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:scaleType="centerCrop"
+            android:src="@drawable/filter_mock" />
+
+        <View
+            android:id="@+id/brightness_overlay"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:visibility="gone" />
+
+        <View
+            android:id="@+id/color_overlay"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:visibility="gone" />
+    </FrameLayout>
+
+    <android.support.v4.widget.NestedScrollView
+        android:layout_width="match_parent"
+        android:layout_height="match_parent">
+
+        <include layout="@layout/reader_color_filter"/>
+
+    </android.support.v4.widget.NestedScrollView>
+
+</LinearLayout>

+ 0 - 263
app/src/main/res/layout/reader_custom_filter_dialog.xml

@@ -1,263 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical">
-
-    <RelativeLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content">
-
-        <android.support.v7.widget.AppCompatImageView
-            android:layout_width="wrap_content"
-            android:layout_height="200dp"
-            android:scaleType="centerCrop"
-            android:src="@drawable/filter_mock" />
-
-        <View
-            android:id="@+id/brightness_overlay"
-            android:layout_width="match_parent"
-            android:layout_height="200dp"
-            android:visibility="gone" />
-
-        <View
-            android:id="@+id/color_overlay"
-            android:layout_width="match_parent"
-            android:layout_height="200dp"
-            android:visibility="gone" />
-    </RelativeLayout>
-
-    <ScrollView
-        android:layout_width="match_parent"
-        android:layout_height="match_parent">
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:orientation="vertical"
-            android:padding="@dimen/material_component_dialogs_padding_around_content_area">
-
-            <android.support.v7.widget.SwitchCompat
-                android:id="@+id/switch_color_filter"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="@string/pref_custom_color_filter" />
-
-            <RelativeLayout
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content">
-
-                <TextView
-                    android:id="@+id/txt_color_filter_red_symbol"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:layout_alignParentLeft="true"
-                    android:layout_alignParentStart="true"
-                    android:layout_centerVertical="true"
-                    android:text="@string/color_filter_r_value"
-                    android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary" />
-
-                <TextView
-                    android:id="@+id/txt_color_filter_red_value"
-                    android:layout_width="30dp"
-                    android:layout_height="wrap_content"
-                    android:layout_alignParentEnd="true"
-                    android:layout_alignParentRight="true"
-                    android:layout_centerVertical="true"
-                    android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary" />
-
-                <SeekBar
-                    android:id="@+id/seekbar_color_filter_red"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_centerVertical="true"
-                    android:layout_marginEnd="8dp"
-                    android:layout_marginLeft="8dp"
-                    android:layout_marginRight="8dp"
-                    android:layout_marginStart="8dp"
-                    android:layout_toEndOf="@id/txt_color_filter_red_symbol"
-                    android:layout_toLeftOf="@id/txt_color_filter_red_value"
-                    android:layout_toRightOf="@id/txt_color_filter_red_symbol"
-                    android:layout_toStartOf="@id/txt_color_filter_red_value"
-                    android:max="255"
-                    android:padding="@dimen/material_component_text_fields_floating_label_padding_between_label_and_input_text" />
-
-            </RelativeLayout>
-
-            <RelativeLayout
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content">
-
-                <TextView
-                    android:id="@+id/txt_color_filter_green_symbol"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:layout_alignParentLeft="true"
-                    android:layout_alignParentStart="true"
-                    android:layout_centerVertical="true"
-                    android:text="@string/color_filter_g_value"
-                    android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary" />
-
-                <TextView
-                    android:id="@+id/txt_color_filter_green_value"
-                    android:layout_width="30dp"
-                    android:layout_height="wrap_content"
-                    android:layout_alignParentEnd="true"
-                    android:layout_alignParentRight="true"
-                    android:layout_centerVertical="true"
-                    android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary" />
-
-                <SeekBar
-                    android:id="@+id/seekbar_color_filter_green"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_centerVertical="true"
-                    android:layout_marginEnd="8dp"
-                    android:layout_marginLeft="8dp"
-                    android:layout_marginRight="8dp"
-                    android:layout_marginStart="8dp"
-                    android:layout_toEndOf="@id/txt_color_filter_green_symbol"
-                    android:layout_toLeftOf="@id/txt_color_filter_green_value"
-                    android:layout_toRightOf="@id/txt_color_filter_green_symbol"
-                    android:layout_toStartOf="@id/txt_color_filter_green_value"
-                    android:max="255"
-                    android:padding="@dimen/material_component_text_fields_floating_label_padding_between_label_and_input_text" />
-
-            </RelativeLayout>
-
-            <RelativeLayout
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content">
-
-                <TextView
-                    android:id="@+id/txt_color_filter_blue_symbol"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:layout_alignParentLeft="true"
-                    android:layout_alignParentStart="true"
-                    android:layout_centerVertical="true"
-                    android:text="@string/color_filter_b_value"
-                    android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary" />
-
-                <TextView
-                    android:id="@+id/txt_color_filter_blue_value"
-                    android:layout_width="30dp"
-                    android:layout_height="wrap_content"
-                    android:layout_alignParentEnd="true"
-                    android:layout_alignParentRight="true"
-                    android:layout_centerVertical="true"
-                    android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary" />
-
-                <SeekBar
-                    android:id="@+id/seekbar_color_filter_blue"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_centerVertical="true"
-                    android:layout_marginEnd="8dp"
-                    android:layout_marginLeft="8dp"
-                    android:layout_marginRight="8dp"
-                    android:layout_marginStart="8dp"
-                    android:layout_toEndOf="@id/txt_color_filter_blue_symbol"
-                    android:layout_toLeftOf="@id/txt_color_filter_blue_value"
-                    android:layout_toRightOf="@id/txt_color_filter_blue_symbol"
-                    android:layout_toStartOf="@id/txt_color_filter_blue_value"
-                    android:max="255"
-                    android:padding="@dimen/material_component_text_fields_floating_label_padding_between_label_and_input_text" />
-            </RelativeLayout>
-
-            <RelativeLayout
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content">
-
-                <TextView
-                    android:id="@+id/txt_color_filter_alpha_symbol"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:layout_alignParentLeft="true"
-                    android:layout_alignParentStart="true"
-                    android:layout_centerVertical="true"
-                    android:text="@string/color_filter_a_value"
-                    android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary" />
-
-                <TextView
-                    android:id="@+id/txt_color_filter_alpha_value"
-                    android:layout_width="30dp"
-                    android:layout_height="wrap_content"
-                    android:layout_alignParentEnd="true"
-                    android:layout_alignParentRight="true"
-                    android:layout_centerVertical="true"
-                    android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary" />
-
-                <SeekBar
-                    android:id="@+id/seekbar_color_filter_alpha"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_centerVertical="true"
-                    android:layout_marginEnd="8dp"
-                    android:layout_marginLeft="8dp"
-                    android:layout_marginRight="8dp"
-                    android:layout_marginStart="8dp"
-                    android:layout_toEndOf="@id/txt_color_filter_alpha_symbol"
-                    android:layout_toLeftOf="@id/txt_color_filter_alpha_value"
-                    android:layout_toRightOf="@id/txt_color_filter_alpha_symbol"
-                    android:layout_toStartOf="@id/txt_color_filter_alpha_value"
-                    android:max="255"
-                    android:padding="@dimen/material_component_text_fields_floating_label_padding_between_label_and_input_text" />
-
-            </RelativeLayout>
-
-            <android.support.v7.widget.SwitchCompat
-                android:id="@+id/custom_brightness"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="@dimen/material_component_cards_primary_title_top_padding"
-                android:text="@string/pref_custom_brightness" />
-
-            <RelativeLayout
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content">
-
-                <android.support.v7.widget.AppCompatImageView
-                    android:id="@+id/txt_brightness_seekbar_icon"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:layout_alignParentLeft="true"
-                    android:layout_alignParentStart="true"
-                    android:layout_centerVertical="true"
-                    android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary"
-                    android:tint="?android:attr/textColorSecondary"
-                    app:srcCompat="@drawable/ic_brightness_5_black_24dp" />
-
-                <TextView
-                    android:id="@+id/txt_brightness_seekbar_value"
-                    android:layout_width="30dp"
-                    android:layout_height="wrap_content"
-                    android:layout_alignParentEnd="true"
-                    android:layout_alignParentRight="true"
-                    android:layout_centerVertical="true"
-                    android:textAppearance="@style/TextAppearance.Regular.SubHeading.Secondary" />
-
-                <eu.kanade.tachiyomi.widget.NegativeSeekBar
-                    android:id="@+id/brightness_seekbar"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_centerVertical="true"
-                    android:layout_marginEnd="8dp"
-                    android:layout_marginLeft="8dp"
-                    android:layout_marginRight="8dp"
-                    android:layout_marginStart="8dp"
-                    android:layout_toEndOf="@id/txt_brightness_seekbar_icon"
-                    android:layout_toLeftOf="@id/txt_brightness_seekbar_value"
-                    android:layout_toRightOf="@id/txt_brightness_seekbar_icon"
-                    android:layout_toStartOf="@id/txt_brightness_seekbar_value"
-                    android:padding="@dimen/material_component_text_fields_floating_label_padding_between_label_and_input_text"
-                    app:max_seek="100"
-                    app:min_seek="-75" />
-
-            </RelativeLayout>
-        </LinearLayout>
-
-    </ScrollView>
-
-</LinearLayout>

+ 0 - 32
app/src/main/res/layout/reader_page_decode_error.xml

@@ -1,32 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    android:layout_width="match_parent"
-    android:layout_height="512dp"
-    android:layout_gravity="center"
-    android:orientation="vertical"
-    android:gravity="center">
-
-    <TextView
-        android:id="@+id/decode_error_text"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="@string/decode_image_error"
-        android:layout_margin="8dp"
-        android:gravity="center"/>
-
-    <Button
-        android:id="@+id/decode_retry"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_margin="8dp"
-        android:text="@string/action_retry"/>
-
-    <Button
-        android:id="@+id/decode_open_browser"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_margin="8dp"
-        android:text="@string/action_open_in_browser"/>
-
-</LinearLayout>

+ 93 - 0
app/src/main/res/layout/reader_page_sheet.xml

@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:background="?android:colorBackground"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical">
+
+    <LinearLayout
+        android:id="@+id/set_as_cover_layout"
+        android:layout_width="match_parent"
+        android:layout_height="56dp"
+        android:paddingLeft="16dp"
+        android:paddingStart="16dp"
+        android:paddingRight="16dp"
+        android:paddingEnd="16dp"
+        android:gravity="center"
+        android:clickable="true"
+        android:focusable="true"
+        android:foreground="?attr/selectableItemBackground">
+
+        <ImageView
+            android:layout_width="24dp"
+            android:layout_height="24dp"
+            app:srcCompat="@drawable/ic_image_black_24dp"
+            android:tint="@color/md_white_1000_54"/>
+
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginLeft="32dp"
+            android:layout_marginStart="32dp"
+            android:text="@string/set_as_cover"/>
+
+    </LinearLayout>
+
+    <LinearLayout
+        android:id="@+id/share_layout"
+        android:layout_width="match_parent"
+        android:layout_height="56dp"
+        android:paddingLeft="16dp"
+        android:paddingStart="16dp"
+        android:paddingRight="16dp"
+        android:paddingEnd="16dp"
+        android:gravity="center"
+        android:clickable="true"
+        android:focusable="true"
+        android:foreground="?attr/selectableItemBackground">
+
+        <ImageView
+            android:layout_width="24dp"
+            android:layout_height="24dp"
+            app:srcCompat="@drawable/ic_share_grey_24dp"/>
+
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginLeft="32dp"
+            android:layout_marginStart="32dp"
+            android:text="@string/action_share"/>
+
+    </LinearLayout>
+
+    <LinearLayout
+        android:id="@+id/save_layout"
+        android:layout_width="match_parent"
+        android:layout_height="56dp"
+        android:paddingLeft="16dp"
+        android:paddingStart="16dp"
+        android:paddingRight="16dp"
+        android:paddingEnd="16dp"
+        android:gravity="center"
+        android:clickable="true"
+        android:focusable="true"
+        android:foreground="?attr/selectableItemBackground">
+
+        <ImageView
+            android:layout_width="24dp"
+            android:layout_height="24dp"
+            app:srcCompat="@drawable/ic_file_download_black_24dp"
+            android:tint="@color/md_white_1000_54"/>
+
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginLeft="32dp"
+            android:layout_marginStart="32dp"
+            android:text="@string/action_save"/>
+
+    </LinearLayout>
+
+</LinearLayout>

+ 0 - 45
app/src/main/res/layout/reader_pager_item.xml

@@ -1,45 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<eu.kanade.tachiyomi.ui.reader.viewer.pager.PageView
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    xmlns:android="http://schemas.android.com/apk/res/android">
-
-    <LinearLayout
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="center_vertical|center_horizontal"
-        android:id="@+id/progress_container"
-        android:orientation="vertical">
-
-        <ProgressBar
-            android:id="@+id/progress"
-            style="?android:attr/progressBarStyleLarge"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center_horizontal"/>
-
-        <TextView
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_marginTop="16dp"
-            android:id="@+id/progress_text"
-            android:layout_gravity="center"
-            android:visibility="invisible"
-            android:textSize="16sp" />
-
-    </LinearLayout>
-
-    <com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
-        android:id="@+id/image_view"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"/>
-
-    <Button
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:id="@+id/retry_button"
-        android:text="@string/action_retry"
-        android:layout_gravity="center"
-        android:visibility="gone"/>
-
-</eu.kanade.tachiyomi.ui.reader.viewer.pager.PageView>

+ 0 - 186
app/src/main/res/layout/reader_settings_dialog.xml

@@ -1,186 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    xmlns:setting="http://schemas.android.com/tools"
-    android:orientation="vertical"
-    android:padding="@dimen/material_component_dialogs_padding_around_content_area"
-    android:divider="@drawable/empty_divider"
-    android:showDividers="middle">
-
-    <!-- Viewer for this series -->
-    <LinearLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="horizontal"
-        android:layout_gravity="center_vertical">
-
-        <TextView
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="1"
-            android:layout_gravity="center_vertical"
-            android:text="@string/viewer_for_this_series" />
-
-        <android.support.v7.widget.AppCompatSpinner
-            android:id="@+id/viewer"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="2"
-            android:entries="@array/viewers_selector">
-
-        </android.support.v7.widget.AppCompatSpinner>
-
-    </LinearLayout>
-
-    <!-- Rotation -->
-    <LinearLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="horizontal"
-        android:layout_gravity="center_vertical">
-
-        <TextView
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="1"
-            android:layout_gravity="center_vertical"
-            android:text="@string/pref_rotation_type" />
-
-        <android.support.v7.widget.AppCompatSpinner
-            android:id="@+id/rotation_mode"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="2"
-            android:entries="@array/rotation_type">
-
-        </android.support.v7.widget.AppCompatSpinner>
-
-    </LinearLayout>
-
-    <!-- Scale type -->
-    <LinearLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="horizontal"
-        android:layout_gravity="center_vertical">
-
-        <TextView
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="1"
-            android:layout_gravity="center_vertical"
-            android:text="@string/pref_image_scale_type" />
-
-        <android.support.v7.widget.AppCompatSpinner
-            android:id="@+id/scale_type"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="2"
-            android:entries="@array/image_scale_type">
-
-        </android.support.v7.widget.AppCompatSpinner>
-
-    </LinearLayout>
-
-    <!-- Zoom start position -->
-    <LinearLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="horizontal"
-        android:layout_gravity="center_vertical">
-
-        <TextView
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="1"
-            android:layout_gravity="center_vertical"
-            android:text="@string/pref_zoom_start" />
-
-        <android.support.v7.widget.AppCompatSpinner
-            android:id="@+id/zoom_start"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="2"
-            android:entries="@array/zoom_start">
-
-        </android.support.v7.widget.AppCompatSpinner>
-
-    </LinearLayout>
-
-    <!-- Image decoder -->
-    <LinearLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="horizontal"
-        android:layout_gravity="center_vertical">
-
-        <TextView
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="1"
-            android:layout_gravity="center_vertical"
-            android:text="@string/pref_image_decoder" />
-
-        <android.support.v7.widget.AppCompatSpinner
-            android:id="@+id/image_decoder"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="2"
-            android:entries="@array/image_decoders">
-
-        </android.support.v7.widget.AppCompatSpinner>
-
-    </LinearLayout>
-
-    <!-- Background color -->
-    <LinearLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="horizontal"
-        android:layout_gravity="center_vertical">
-
-        <TextView
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="1"
-            android:layout_gravity="center_vertical"
-            android:text="@string/pref_reader_theme" />
-
-        <android.support.v7.widget.AppCompatSpinner
-            android:id="@+id/background_color"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="2"
-            android:entries="@array/reader_themes">
-
-        </android.support.v7.widget.AppCompatSpinner>
-
-    </LinearLayout>
-
-    <android.support.v7.widget.SwitchCompat
-        android:id="@+id/show_page_number"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:text="@string/pref_show_page_number"/>
-
-    <android.support.v7.widget.SwitchCompat
-        android:id="@+id/crop_borders"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:text="@string/pref_crop_borders"/>
-
-    <android.support.v7.widget.SwitchCompat
-        android:id="@+id/crop_borders_webtoon"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:text="@string/pref_crop_borders"/>
-
-    <android.support.v7.widget.SwitchCompat
-        android:id="@+id/fullscreen"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:text="@string/pref_fullscreen"/>
-
-</LinearLayout>

+ 247 - 0
app/src/main/res/layout/reader_settings_sheet.xml

@@ -0,0 +1,247 @@
+<?xml version="1.0" encoding="utf-8"?>
+<android.support.constraint.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="?android:colorBackground"
+    android:clipToPadding="false"
+    android:orientation="vertical"
+    android:padding="@dimen/material_component_dialogs_padding_around_content_area">
+
+    <!-- General preferences -->
+
+    <TextView
+        android:id="@+id/general_prefs"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:text="@string/pref_category_general"
+        android:textColor="?attr/colorAccent"
+        android:textStyle="bold"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+    <TextView
+        android:id="@+id/pull_up_for_more"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginLeft="16dp"
+        android:text="Pull up for more options"
+        android:textColor="?android:attr/textColorHint"
+        app:layout_constraintLeft_toRightOf="@id/general_prefs"
+        app:layout_constraintTop_toTopOf="@id/general_prefs" />
+
+    <android.support.v4.widget.Space
+        android:id="@+id/spinner_end"
+        android:layout_width="16dp"
+        android:layout_height="0dp"
+        app:layout_constraintLeft_toRightOf="parent" />
+
+    <TextView
+        android:id="@+id/viewer_text"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:text="@string/viewer_for_this_series"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toLeftOf="@id/verticalcenter"
+        app:layout_constraintBaseline_toBaselineOf="@id/viewer" />
+
+    <android.support.v7.widget.AppCompatSpinner
+        android:id="@+id/viewer"
+        android:layout_width="0dp"
+        android:layout_height="24dp"
+        android:layout_marginTop="16dp"
+        android:entries="@array/viewers_selector"
+        app:layout_constraintTop_toBottomOf="@id/pull_up_for_more"
+        app:layout_constraintLeft_toRightOf="@id/verticalcenter"
+        app:layout_constraintRight_toRightOf="@id/spinner_end" />
+
+    <TextView
+        android:id="@+id/rotation_mode_text"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:text="@string/pref_rotation_type"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toLeftOf="@id/verticalcenter"
+        app:layout_constraintBaseline_toBaselineOf="@id/rotation_mode" />
+
+    <android.support.v7.widget.AppCompatSpinner
+        android:id="@+id/rotation_mode"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:entries="@array/rotation_type"
+        app:layout_constraintTop_toBottomOf="@id/viewer"
+        app:layout_constraintLeft_toRightOf="@id/verticalcenter"
+        app:layout_constraintRight_toRightOf="@id/spinner_end" />
+
+    <TextView
+        android:id="@+id/background_color_text"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:text="@string/pref_reader_theme"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toLeftOf="@id/background_color"
+        app:layout_constraintBaseline_toBaselineOf="@id/background_color"/>
+
+    <android.support.v7.widget.AppCompatSpinner
+        android:id="@+id/background_color"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:entries="@array/reader_themes"
+        app:layout_constraintTop_toBottomOf="@id/rotation_mode"
+        app:layout_constraintLeft_toRightOf="@id/verticalcenter"
+        app:layout_constraintRight_toRightOf="@id/spinner_end" />
+
+    <android.support.v7.widget.SwitchCompat
+        android:id="@+id/show_page_number"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:text="@string/pref_show_page_number"
+        android:textColor="?android:attr/textColorSecondary"
+        app:layout_constraintTop_toBottomOf="@id/background_color" />
+
+    <android.support.v7.widget.SwitchCompat
+        android:id="@+id/fullscreen"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:text="@string/pref_fullscreen"
+        android:textColor="?android:attr/textColorSecondary"
+        app:layout_constraintTop_toBottomOf="@id/show_page_number" />
+
+    <android.support.v7.widget.SwitchCompat
+        android:id="@+id/keepscreen"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:text="@string/pref_keep_screen_on"
+        android:textColor="?android:attr/textColorSecondary"
+        app:layout_constraintTop_toBottomOf="@id/fullscreen" />
+
+    <android.support.v4.widget.Space
+        android:id="@+id/end_general_preferences"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        app:layout_constraintBottom_toBottomOf="@id/keepscreen" />
+
+    <!-- Pager preferences -->
+
+    <TextView
+        android:id="@+id/pager_prefs"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:text="@string/pager_viewer"
+        android:textColor="?attr/colorAccent"
+        android:textStyle="bold"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/end_general_preferences" />
+
+    <TextView
+        android:id="@+id/scale_type_text"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:text="@string/pref_image_scale_type"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toLeftOf="@id/verticalcenter"
+        app:layout_constraintBaseline_toBaselineOf="@id/scale_type"/>
+
+    <android.support.v7.widget.AppCompatSpinner
+        android:id="@+id/scale_type"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:entries="@array/image_scale_type"
+        app:layout_constraintLeft_toRightOf="@id/verticalcenter"
+        app:layout_constraintRight_toRightOf="@id/spinner_end"
+        app:layout_constraintTop_toBottomOf="@id/pager_prefs"/>
+
+    <TextView
+        android:id="@+id/zoom_start_text"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:text="@string/pref_zoom_start"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toLeftOf="@id/verticalcenter"
+        app:layout_constraintBaseline_toBaselineOf="@id/zoom_start"/>
+
+    <android.support.v7.widget.AppCompatSpinner
+        android:id="@+id/zoom_start"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:entries="@array/zoom_start"
+        android:layout_marginTop="16dp"
+        app:layout_constraintTop_toBottomOf="@id/scale_type"
+        app:layout_constraintLeft_toRightOf="@id/verticalcenter"
+        app:layout_constraintRight_toRightOf="@id/spinner_end" />
+
+    <android.support.v7.widget.SwitchCompat
+        android:id="@+id/crop_borders"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:text="@string/pref_crop_borders"
+        android:textColor="?android:attr/textColorSecondary"
+        app:layout_constraintTop_toBottomOf="@id/zoom_start" />
+
+    <android.support.v7.widget.SwitchCompat
+        android:id="@+id/page_transitions"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:text="@string/pref_page_transitions"
+        android:textColor="?android:attr/textColorSecondary"
+        app:layout_constraintTop_toBottomOf="@id/crop_borders" />
+
+    <!-- Webtoon preferences -->
+
+    <TextView
+        android:id="@+id/webtoon_prefs"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:text="@string/webtoon_viewer"
+        android:textColor="?attr/colorAccent"
+        android:textStyle="bold"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/end_general_preferences" />
+
+    <android.support.v7.widget.SwitchCompat
+        android:id="@+id/crop_borders_webtoon"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:text="@string/pref_crop_borders"
+        android:textColor="?android:attr/textColorSecondary"
+        app:layout_constraintTop_toBottomOf="@id/webtoon_prefs" />
+
+    <!-- Groups of preferences -->
+
+    <android.support.constraint.Group
+        android:id="@+id/pager_prefs_group"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:visibility="gone"
+        app:constraint_referenced_ids="pager_prefs,scale_type_text,scale_type,zoom_start_text,zoom_start,crop_borders,page_transitions"
+        tools:visibility="visible" />
+
+    <android.support.constraint.Group
+        android:id="@+id/webtoon_prefs_group"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:visibility="gone"
+        app:constraint_referenced_ids="webtoon_prefs,crop_borders_webtoon" />
+
+    <android.support.constraint.Guideline
+        android:id="@+id/verticalcenter"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:orientation="vertical"
+        app:layout_constraintGuide_percent="0.5" />
+
+</android.support.constraint.ConstraintLayout>

+ 0 - 55
app/src/main/res/layout/reader_webtoon_item.xml

@@ -1,55 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<FrameLayout
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    xmlns:android="http://schemas.android.com/apk/res/android">
-
-    <LinearLayout
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="center_horizontal"
-        android:layout_marginTop="32dp"
-        android:id="@+id/progress_container"
-        android:orientation="vertical">
-
-        <ProgressBar
-            android:id="@+id/progress"
-            style="?android:attr/progressBarStyleLarge"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center_horizontal"/>
-
-        <TextView
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_marginTop="16dp"
-            android:id="@+id/progress_text"
-            android:layout_gravity="center"
-            android:visibility="invisible"
-            android:textSize="16sp" />
-
-    </LinearLayout>
-
-    <com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
-        android:id="@+id/image_view"
-        xmlns:android="http://schemas.android.com/apk/res/android"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"/>
-
-    <FrameLayout
-        android:layout_width="wrap_content"
-        android:layout_height="192dp"
-        android:layout_gravity="center_horizontal"
-        android:id="@+id/retry_container"
-        android:visibility="gone">
-
-        <Button
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:id="@+id/retry_button"
-            android:text="@string/action_retry"
-            android:layout_gravity="center"/>
-
-    </FrameLayout>
-
-</FrameLayout>

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini