diff --git a/app/build.gradle b/app/build.gradle index a0128b004..49005a535 100644 --- a/app/build.gradle +++ b/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() } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a64d4d0b2..653729043 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,8 +32,7 @@ + android:name=".ui.reader.ReaderActivity" /> - - - - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt index d4b80ad65..bc6d6b42e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt +++ b/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, 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. * diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt index 17efd8e95..4e23599d0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt +++ b/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() + /** * Downloads provider, used to retrieve the folders where the chapters are or should be stored. */ @@ -27,12 +34,17 @@ class DownloadManager(context: Context) { /** * Cache of downloaded chapters. */ - private val cache = DownloadCache(context, provider) + private val cache = DownloadCache(context, provider, sourceManager) /** * Downloader whose only task is to download chapters. */ - private val downloader = Downloader(context, provider, cache) + private val downloader = Downloader(context, provider, cache, sourceManager) + + /** + * Queue to delay the deletion of a list of chapters until triggered. + */ + private val pendingDeleter = DownloadPendingDeleter(context) /** * Downloads queue, where the pending chapters are stored. @@ -146,15 +158,20 @@ class DownloadManager(context: Context) { } /** - * Deletes the directory of a downloaded chapter. + * Deletes the directories of a list of downloaded chapters. * - * @param chapter the chapter to delete. - * @param manga the manga of the chapter. - * @param source the source of the chapter. + * @param chapters the list of chapters to delete. + * @param manga the manga of the chapters. + * @param source the source of the chapters. */ - fun deleteChapter(chapter: Chapter, manga: Manga, source: Source) { - provider.findChapterDir(chapter, manga, source)?.delete() - cache.removeChapter(chapter, manga) + fun deleteChapters(chapters: List, 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, 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) + } + } + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadPendingDeleter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadPendingDeleter.kt new file mode 100644 index 000000000..894b9e493 --- /dev/null +++ b/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() + + /** + * 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, 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(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> { + 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 { + return prefs.all.values.mapNotNull { rawEntry -> + try { + (rawEntry as? String)?.let { gson.fromJson(it) } + } catch (e: Exception) { + null + } + } + } + + /** + * Returns a copy of chapter entries ensuring no duplicates by chapter id. + */ + private fun List.addUniqueById(chapters: List): List { + 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, + 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 + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt index e70563777..e38bb88e2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt +++ b/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, manga: Manga, source: Source): List { + 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) } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt index 4edf6b761..e9754053c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt +++ b/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 { val objs = preferences.all .mapNotNull { it.value as? String } - .map { deserialize(it) } + .mapNotNull { deserialize(it) } .sortedBy { it.order } val downloads = mutableListOf() @@ -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) -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index a95d252b3..0ca544936 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/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" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt index 8e74d8042..197140d0f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt +++ b/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) { + 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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/glide/PassthroughModelLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/glide/PassthroughModelLoader.kt new file mode 100644 index 000000000..bb117086e --- /dev/null +++ b/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 { + + override fun buildLoadData( + model: InputStream, + width: Int, + height: Int, + options: Options + ): ModelLoader.LoadData? { + return ModelLoader.LoadData(ObjectKey(model), Fetcher(model)) + } + + override fun handles(model: InputStream): Boolean { + return true + } + + class Fetcher(private val stream: InputStream) : DataFetcher { + + override fun getDataClass(): Class { + 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 + ) { + callback.onDataReady(stream) + } + + } + + /** + * Factory class for creating [PassthroughModelLoader] instances. + */ + class Factory : ModelLoaderFactory { + + override fun build( + multiFactory: MultiModelLoaderFactory + ): ModelLoader { + return PassthroughModelLoader() + } + + override fun teardown() {} + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/glide/TachiGlideModule.kt b/app/src/main/java/eu/kanade/tachiyomi/data/glide/TachiGlideModule.kt index 457f8d228..1eecf3eed 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/glide/TachiGlideModule.kt +++ b/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()) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index 6caa902f5..985187f77 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/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" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index f329bd904..5de266b6a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt index d46370e7c..4dd8d4936 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/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 { 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> { + return Observable.error(Exception("Unused")) + } + + private fun isSupportedFile(extension: String): Boolean { + return extension.toLowerCase() in setOf("zip", "rar", "cbr", "cbz", "epub") + } + + fun getFormat(chapter: SChapter): Format { val baseDirs = getBaseDirectories(context) for (dir in baseDirs) { val chapFile = File(dir, chapter.url) if (!chapFile.exists()) continue - return Observable.just(getLoader(chapFile).load()) + return getFormat(chapFile) } - - return Observable.error(Exception("Chapter not found")) + throw Exception("Chapter not found") } - private fun isSupportedFormat(extension: String): Boolean { - return extension.equals("zip", true) || extension.equals("cbz", true) - || extension.equals("rar", true) || extension.equals("cbr", true) - || extension.equals("epub", true) - } - - private fun getLoader(file: File): Loader { + private fun getFormat(file: File): Format { val extension = file.extension return if (file.isDirectory) { - DirectoryLoader(file) + Format.Directory(file) } else if (extension.equals("zip", true) || extension.equals("cbz", true)) { - ZipLoader(file) - } else if (extension.equals("epub", true)) { - EpubLoader(file) + Format.Zip(file) } else if (extension.equals("rar", true) || extension.equals("cbr", true)) { - RarLoader(file) + Format.Rar(file) + } else if (extension.equals("epub", true)) { + Format.Epub(file) } else { throw Exception("Invalid chapter format") } } + private fun updateCover(chapter: SChapter, manga: SManga): File? { + val format = getFormat(chapter) + val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + return when (format) { + is Format.Directory -> { + val entry = format.file.listFiles() + .sortedWith(Comparator { f1, f2 -> comparator.compare(f1.name, f2.name) }) + .find { !it.isDirectory && ImageUtil.isImage(it.name, { FileInputStream(it) }) } + + entry?.let { updateCover(context, manga, it.inputStream())} + } + is Format.Zip -> { + ZipFile(format.file).use { zip -> + val entry = zip.entries().toList() + .sortedWith(Comparator { f1, f2 -> comparator.compare(f1.name, f2.name) }) + .find { !it.isDirectory && ImageUtil.isImage(it.name, { zip.getInputStream(it) }) } + + entry?.let { updateCover(context, manga, zip.getInputStream(it) )} + } + } + is Format.Rar -> { + Archive(format.file).use { archive -> + val entry = archive.fileHeaders + .sortedWith(Comparator { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) }) + .find { !it.isDirectory && ImageUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) } + + entry?.let { updateCover(context, manga, archive.getInputStream(it) )} + } + } + is Format.Epub -> { + EpubFile(format.file).use { epub -> + val entry = epub.getImagesFromPages() + .firstOrNull() + ?.let { epub.getEntry(it) } + + entry?.let { updateCover(context, manga, epub.getInputStream(it)) } + } + } + } + } + private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true)) override fun getFilterList() = FilterList(OrderBy()) - interface Loader { - fun load(): List + sealed class Format { + data class Directory(val file: File) : Format() + data class Zip(val file: File) : Format() + data class Rar(val file: File): Format() + data class Epub(val file: File) : Format() } - class DirectoryLoader(val file: File) : Loader { - override fun load(): List { - val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() - return file.listFiles() - .filter { !it.isDirectory && DiskUtil.isImage(it.name, { FileInputStream(it) }) } - .sortedWith(Comparator { f1, f2 -> comparator.compare(f1.name, f2.name) }) - .map { Uri.fromFile(it) } - .mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } } - } - } - - class ZipLoader(val file: File) : Loader { - override fun load(): List { - val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() - return ZipFile(file).use { zip -> - zip.entries().toList() - .filter { !it.isDirectory && DiskUtil.isImage(it.name, { zip.getInputStream(it) }) } - .sortedWith(Comparator { f1, f2 -> comparator.compare(f1.name, f2.name) }) - .map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/${it.name}") } - .mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } } - } - } - } - - class RarLoader(val file: File) : Loader { - override fun load(): List { - val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() - return Archive(file).use { archive -> - archive.fileHeaders - .filter { !it.isDirectory && DiskUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) } - .sortedWith(Comparator { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) }) - .map { Uri.parse("content://${RarContentProvider.PROVIDER}${file.absolutePath}!-/${it.fileNameString}") } - .mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } } - } - } - } - - class EpubLoader(val file: File) : Loader { - - override fun load(): List { - ZipFile(file).use { zip -> - val allEntries = zip.entries().toList() - val ref = getPackageHref(zip) - val doc = getPackageDocument(zip, ref) - val pages = getPagesFromDocument(doc) - val hrefs = getHrefMap(ref, allEntries.map { it.name }) - return getImagesFromPages(zip, pages, hrefs) - .map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/$it") } - .mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } } - } - } - - /** - * Returns the path to the package document. - */ - private fun getPackageHref(zip: ZipFile): String { - val meta = zip.getEntry("META-INF/container.xml") - if (meta != null) { - val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") } - val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path") - if (path != null) { - return path - } - } - return "OEBPS/content.opf" - } - - /** - * Returns the package document where all the files are listed. - */ - private fun getPackageDocument(zip: ZipFile, ref: String): Document { - val entry = zip.getEntry(ref) - return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } - } - - /** - * Returns all the pages from the epub. - */ - private fun getPagesFromDocument(document: Document): List { - val pages = document.select("manifest > item") - .filter { "application/xhtml+xml" == it.attr("media-type") } - .associateBy { it.attr("id") } - - val spine = document.select("spine > itemref").map { it.attr("idref") } - return spine.mapNotNull { pages[it] }.map { it.attr("href") } - } - - /** - * Returns all the images contained in every page from the epub. - */ - private fun getImagesFromPages(zip: ZipFile, pages: List, hrefs: Map): List { - 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): Map { - 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 - } - } - } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt index 2bf008d4c..00cb40e55 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt +++ b/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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt index 02810c7bd..e69581df3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt +++ b/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> { - 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 { - return if (page.imageUrl.isNullOrEmpty()) - getImageUrl(page).flatMap { getCachedImage(it) } - else - getCachedImage(page) -} fun HttpSource.getImageUrl(page: Page): Observable { 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 { - 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.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): Observable { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt index 3a2efcc5c..88e835b47 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt +++ b/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) { - 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) { + downloadManager.deleteChapters(chapters, manga, source) + chapters.forEach { + it.status = Download.NOT_DOWNLOADED + it.download = null + } } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoadStrategy.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoadStrategy.kt new file mode 100644 index 000000000..e25abc014 --- /dev/null +++ b/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): List { + return allChapters.sortedByDescending { it.source_order } + } +} + +/** + * Load strategy using unique chapter numbers with same scanlator preference. + */ +class ChapterLoadByNumber { + fun get(allChapters: List, selectedChapter: Chapter): List { + val chapters = mutableListOf() + 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 } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoader.kt deleted file mode 100644 index 5c937715b..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoader.kt +++ /dev/null @@ -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() - 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 { - - 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) - } - - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageIndicatorTextView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageIndicatorTextView.kt index 51527cbda..7fa36a2a3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageIndicatorTextView.kt +++ b/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 }!! } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index 94e4329fc..e73d27687 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -1,50 +1,94 @@ 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() { + /** + * Preferences helper. + */ + private val preferences by injectLazy() + + /** + * The maximum bitmap size supported by the device. + */ + val maxBitmapSize by lazy { GLUtil.getMaxTextureSize() } + + /** + * Viewer used to display the pages (pager, webtoon, ...). + */ + var viewer: BaseViewer? = null + private set + + /** + * Whether the menu is currently visible. + */ + var menuVisible = false + private set + + /** + * System UI helper to hide status & navigation bar on all different API levels. + */ + private var systemUi: SystemUiHelper? = null + + /** + * Configuration at reader level, like background color or forced orientation. + */ + private var config: ReaderConfig? = null + + /** + * Progress dialog used when switching chapters from the menu buttons. + */ + @Suppress("DEPRECATION") + private var progressDialog: ProgressDialog? = null + companion object { @Suppress("unused") const val LEFT_TO_RIGHT = 1 @@ -52,113 +96,74 @@ class ReaderActivity : BaseRxActivity() { 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) + val intent = Intent(context, ReaderActivity::class.java) + intent.putExtra("manga", manga) + intent.putExtra("chapter", chapter.id) + return intent } } - private var viewer: BaseReader? = null - - val subscriptions by lazy { CompositeSubscription() } - - private var customBrightnessSubscription: Subscription? = null - - private var customFilterColorSubscription: Subscription? = null - - var readerTheme: Int = 0 - private set - - var maxBitmapSize: Int = 0 - private set - - private val decimalFormat = DecimalFormat("#.###") - - private val volumeKeysEnabled by lazy { preferences.readWithVolumeKeys().getOrDefault() } - - private val volumeKeysInverted by lazy { preferences.readWithVolumeKeysInverted().getOrDefault() } - - val preferences by injectLazy() - - private var systemUi: SystemUiHelper? = null - - private var menuVisible = false - + /** + * 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) - - maxBitmapSize = GLUtil.getMaxTextureSize() - - 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() - } - } + config = ReaderConfig() + initializeMenu() } + /** + * Called when the activity is destroyed. Cleans up the viewer, configuration and any view. + */ override fun onDestroy() { - toolbar.setNavigationOnClickListener(null) - subscriptions.unsubscribe() - viewer = null super.onDestroy() + viewer?.destroy() + viewer = null + config?.destroy() + config = null + progressDialog?.dismiss() + progressDialog = null } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.reader, menu) - return true - } - - 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") - else -> return super.onOptionsItemSelected(item) - } - return true - } - + /** + * 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(MENU_VISIBLE, menuVisible) + outState.putBoolean(::menuVisible.name, menuVisible) + if (!isChangingConfigurations) { + presenter.onSaveInstanceStateNonConfigurationChange() + } super.onSaveInstanceState(outState) } + /** + * 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) { @@ -166,359 +171,97 @@ class ReaderActivity : BaseRxActivity() { } } - 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() - } + /** + * 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 } - 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 - } - } - } - } - 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) - } + /** + * 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 -> ReaderSettingsSheet(this).show() + R.id.action_custom_filter -> ReaderColorFilterSheet(this).show() + else -> return super.onOptionsItemSelected(item) } return true } - fun onChapterError(error: Throwable) { - Timber.e(error) - finish() - toast(error.message) - } - - 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) - } - }.show() - } - - fun onChapterAppendError() { - // Ignore + /** + * 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() } /** - * Called from the presenter at startup, allowing to prepare the selected reader. + * Dispatches a key event. If the viewer doesn't handle it, call the default implementation. */ - fun onMangaOpen(manga: Manga) { - if (viewer == null) { - viewer = getOrCreateViewer(manga) + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val handled = viewer?.handleKeyEvent(event) ?: false + return handled || super.dispatchKeyEvent(event) + } + + /** + * Dispatches a generic motion event. If the viewer doesn't handle it, call the default + * implementation. + */ + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + val handled = viewer?.handleGenericMotionEvent(event) ?: false + return handled || super.dispatchGenericMotionEvent(event) + } + + /** + * 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() } - if (viewer is RightToLeftReader && page_seekbar.rotation != 180f) { - // Invert the seekbar for the right to left reader - page_seekbar.rotation = 180f - } - supportActionBar?.title = manga.title - please_wait.visibility = View.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) - } - - fun onEnterChapter(chapter: ReaderChapter, currentPage: Int) { - val activePage = if (currentPage == -1) chapter.pages!!.lastIndex else currentPage - presenter.setActiveChapter(chapter) - setActiveChapter(chapter, activePage) - } - - 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}" - } else { - left_page_text.text = "$numPages" - right_page_text.text = "${currentPage + 1}" - } - 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) - } - - 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 - } - - 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 - } - - 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" - } else { - right_page_text.text = "$pageNumber" - } - 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) - } - } - } - - fun toggleMenu() { - setMenuVisibility(!menuVisible) - } - - fun requestNextChapter() { - if (!presenter.loadNextChapter()) { - toast(R.string.no_next_chapter) - } - } - - fun requestPreviousChapter() { - if (!presenter.loadPreviousChapter()) { - toast(R.string.no_previous_chapter) - } - } - - private fun initializeBottomMenu() { - // Intercept all events in this layout - reader_menu_bottom.setOnTouchListener { _, _ -> true } + // Init listeners on bottom menu page_seekbar.setOnSeekBarChangeListener(object : SimpleSeekBarListener() { override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { - if (fromUser) { - gotoPageInCurrentChapter(value) + if (viewer != null && fromUser) { + moveToPageIndex(value) } } }) - } - - private fun initializeSettings() { - subscriptions += preferences.rotation().asObservable() - .subscribe { setRotation(it) } - - subscriptions += preferences.showPageNumber().asObservable() - .subscribe { setPageNumberVisibility(it) } - - subscriptions += preferences.fullscreen().asObservable() - .subscribe { setFullscreen(it) } - - subscriptions += preferences.keepScreenOn().asObservable() - .subscribe { setKeepScreenOn(it) } - - subscriptions += preferences.customBrightness().asObservable() - .subscribe { setCustomBrightness(it) } - - subscriptions += preferences.colorFilter().asObservable() - .subscribe { setColorFilter(it) } - - subscriptions += preferences.readerTheme().asObservable() - .distinctUntilChanged() - .subscribe { applyTheme(it) } - } - - 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) + left_chapter.setOnClickListener { + if (viewer != null) { + if (viewer is R2LPagerViewer) + loadNextChapter() + else + loadPreviousChapter() } - // 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 - } - - 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 + right_chapter.setOnClickListener { + if (viewer != null) { + if (viewer is R2LPagerViewer) + loadPreviousChapter() + else + loadNextChapter() + } } - } - 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() - .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) - .subscribe { setCustomBrightnessValue(it) } - - subscriptions.add(customBrightnessSubscription) - } else { - customBrightnessSubscription?.let { subscriptions.remove(it) } - setCustomBrightnessValue(0) - } - } - - 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 - } + // Set initial visibility + setMenuVisibility(menuVisible) } /** - * 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. + * Sets the visibility of the menu according to [visible] and with an optional parameter to + * [animate] the views. */ - 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) { @@ -531,14 +274,15 @@ class ReaderActivity : BaseRxActivity() { 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) + window.addFlags( + WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) } } }) toolbar.startAnimation(toolbarAnimation) - val bottomMenuAnimation = AnimationUtils.loadAnimation(this, R.anim.enter_from_bottom) - reader_menu_bottom.startAnimation(bottomMenuAnimation) + val bottomAnimation = AnimationUtils.loadAnimation(this, R.anim.enter_from_bottom) + reader_menu_bottom.startAnimation(bottomAnimation) } } else { systemUi?.hide() @@ -552,27 +296,179 @@ class ReaderActivity : BaseRxActivity() { }) toolbar.startAnimation(toolbarAnimation) - val bottomMenuAnimation = AnimationUtils.loadAnimation(this, R.anim.exit_to_bottom) - reader_menu_bottom.startAnimation(bottomMenuAnimation) + val bottomAnimation = AnimationUtils.loadAnimation(this, R.anim.exit_to_bottom) + reader_menu_bottom.startAnimation(bottomAnimation) } } } /** - * Start a share intent that lets user share image - * - * @param page page object containing image information. + * Called from the presenter when a manga is ready. Used to instantiate the appropriate viewer + * and the toolbar title. */ - 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) + 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) } + + // Destroy previous viewer if there was one + if (prevViewer != null) { + prevViewer.destroy() + viewer_container.removeAllViews() + } + 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)) + } + + /** + * 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 + } + + /** + * 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) + } + + /** + * 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 { + null + } + } + + /** + * 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) + } + + /** + * 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() + } + + /** + * 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() + } + + /** + * 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 = "${page.number}" + left_page_text.text = "${pages.size}" + } + + // Set seekbar progress + page_seekbar.max = pages.lastIndex + page_seekbar.progress = page.index + } + + /** + * Called from the viewer whenever a [page] is long clicked. A bottom sheet with a list of + * actions to perform is shown. + */ + fun onPageLongTap(page: ReaderPage) { + 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) + } + + /** + * 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) + } + + /** + * 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, uri) + putExtra(Intent.EXTRA_STREAM, stream) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION type = "image/*" } @@ -580,20 +476,238 @@ class ReaderActivity : BaseRxActivity() { } /** - * Sets the given page as the cover of the manga. - * - * @param page the page containing the image to set as cover. + * Called from the page sheet. It delegates saving the image of the given [page] on external + * storage to the presenter. */ - private fun setImageAsCover(page: Page) { - if (page.status != Page.READY) - return + fun saveImage(page: ReaderPage) { + presenter.saveImage(page) + } - 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() + /** + * 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) + } + } + } + + /** + * 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) + } + + /** + * 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() + .subscribe { setFullscreen(it) } + + subscriptions += preferences.keepScreenOn().asObservable() + .subscribe { setKeepScreenOn(it) } + + subscriptions += preferences.customBrightness().asObservable() + .subscribe { setCustomBrightness(it) } + + subscriptions += preferences.colorFilter().asObservable() + .subscribe { setColorFilter(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 + } + + if (newOrientation != requestedOrientation) { + requestedOrientation = newOrientation + } + } + + /** + * 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 + } + + /** + * 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 + } + } + + /** + * Sets the keep screen on mode according to [enabled]. + */ + private fun setKeepScreenOn(enabled: Boolean) { + if (enabled) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + /** + * Sets the custom brightness overlay according to [enabled]. + */ + 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) + } + } + + /** + * 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 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 + } + } + + /** + * Sets the color filter [value]. + */ + private fun setColorFilterValue(value: Int) { + color_overlay.visibility = View.VISIBLE + color_overlay.setBackgroundColor(value) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderChapter.kt deleted file mode 100644 index e9bd9a0c4..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderChapter.kt +++ /dev/null @@ -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? = null - - var isDownloaded: Boolean = false - - var requestedPage: Int = 0 -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderCustomFilterDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterSheet.kt similarity index 81% rename from app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderCustomFilterDialog.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterSheet.kt index d76a5156f..263bd5de2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderCustomFilterDialog.kt +++ b/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() + private var behavior: BottomSheetBehavior<*>? = null + /** - * Subscription used for filter overlay + * Subscriptions used for this dialog */ - private lateinit var subscriptions: CompositeSubscription + private val subscriptions = CompositeSubscription() /** * Subscription used for custom brightness overlay @@ -59,34 +44,18 @@ class ReaderCustomFilterDialog : DialogFragment() { */ private var customFilterColorSubscription: Subscription? = null - /** - * This method will be called after onCreate(Bundle) - * @param savedState The last saved instance state of the Fragment. - */ - override fun onCreateDialog(savedState: Bundle?): Dialog { - val dialog = MaterialDialog.Builder(activity!!) - .customView(R.layout.reader_custom_filter_dialog, false) - .positiveText(android.R.string.ok) - .build() + init { + val view = activity.layoutInflater.inflate(R.layout.reader_color_filter_sheet, null) + setContentView(view) - subscriptions = CompositeSubscription() - onViewCreated(dialog.view, savedState) + behavior = BottomSheetBehavior.from(view.parent as ViewGroup) - return dialog - } - - /** - * Called immediately after onCreateView() - * @param view The View returned by onCreateDialog. - * @param savedInstanceState If non-null, this fragment is being re-constructed - */ - override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(view) { // Initialize subscriptions. subscriptions += preferences.colorFilter().asObservable() - .subscribe { setColorFilter(it, view) } + .subscribe { setColorFilter(it, view) } subscriptions += preferences.customBrightness().asObservable() - .subscribe { setCustomBrightness(it, view) } + .subscribe { setCustomBrightness(it, view) } // Get color and update values val color = preferences.colorFilterValue().getOrDefault() @@ -154,7 +123,19 @@ class ReaderCustomFilterDialog : DialogFragment() { } } }) + } + override fun onStart() { + super.onStart() + behavior?.skipCollapsed = true + behavior?.state = BottomSheetBehavior.STATE_EXPANDED + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + subscriptions.unsubscribe() + customBrightnessSubscription = null + customFilterColorSubscription = null } /** @@ -210,8 +191,8 @@ class ReaderCustomFilterDialog : DialogFragment() { private fun setCustomBrightness(enabled: Boolean, view: View) { if (enabled) { customBrightnessSubscription = preferences.customBrightnessValue().asObservable() - .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) - .subscribe { setCustomBrightnessValue(it, view) } + .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) + .subscribe { setCustomBrightnessValue(it, view) } subscriptions.add(customBrightnessSubscription) } else { @@ -249,13 +230,13 @@ class ReaderCustomFilterDialog : DialogFragment() { private fun setColorFilter(enabled: Boolean, view: View) { if (enabled) { customFilterColorSubscription = preferences.colorFilterValue().asObservable() - .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) - .subscribe { setColorFilterValue(it, view) } + .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) + .subscribe { setColorFilterValue(it, view) } subscriptions.add(customFilterColorSubscription) } else { customFilterColorSubscription?.let { subscriptions.remove(it) } - view.color_overlay.visibility = View.GONE + color_overlay.visibility = View.GONE } setColorFilterSeekBar(enabled, view) } @@ -319,12 +300,18 @@ class ReaderCustomFilterDialog : DialogFragment() { return color and 0xFF } - /** - * Called when dialog is dismissed - */ - override fun onDestroyView() { - subscriptions.unsubscribe() - super.onDestroyView() + private companion object { + /** Integer mask of alpha value **/ + const val ALPHA_MASK: Long = 0xFF000000 + + /** Integer mask of red value **/ + const val RED_MASK: Long = 0x00FF0000 + + /** Integer mask of green value **/ + const val GREEN_MASK: Long = 0x0000FF00 + + /** Integer mask of blue value **/ + const val BLUE_MASK: Long = 0x000000FF } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderEvent.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderEvent.kt deleted file mode 100644 index 04a1a403f..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderEvent.kt +++ /dev/null @@ -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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPageSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPageSheet.kt new file mode 100644 index 000000000..7212d01ac --- /dev/null +++ b/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() + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt index 0ca814f17..eae1729ac 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt +++ b/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() { - 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 /** - * Previous chapter of the active. + * The chapter loader for the loaded manga. It'll be null until [manga] is set. */ - private var prevChapter: ReaderChapter? = null + private var loader: ChapterLoader? = null /** - * Next chapter of the active. + * Subscription to prevent setting chapters as active from multiple threads. */ - private var nextChapter: ReaderChapter? = null + private var activeChapterSubscription: Subscription? = null /** - * Source of the manga. + * Relay for currently active viewer chapters. */ - private val source by lazy { sourceManager.getOrStub(manga.source) } + private val viewerChaptersRelay = BehaviorRelay.create() + + /** + * Relay used when loading prev/next chapter needed to lock the UI (with a dialog). + */ + private val isLoadingAdjacentChapterRelay = BehaviorRelay.create() /** * 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 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") - 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 { c1, c2 -> sortFunction(c1, c2) }) + 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() - - /** - * List of manga services linked to the active manga, or null if auto syncing is not enabled. - */ - private var trackList: List? = null - - /** - * Chapter loader whose job is to obtain the chapter list and initialize every page. - */ - private val loader by lazy { ChapterLoader(downloadManager, manga, source) } - - /** - * Subscription for appending a chapter to the reader (seamless mode). - */ - private var appenderSubscription: Subscription? = null - - /** - * Subscription for retrieving the adjacent chapters to the current one. - */ - private var adjacentChaptersSubscription: Subscription? = null - - /** - * Whether the active chapter has been loaded. - */ - private var chapterLoaded = false - - companion object { - /** - * Id of the restartable that loads the active chapter. - */ - private const val LOAD_ACTIVE_CHAPTER = 1 - } - 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) }) - - // Retrieve the sync list if auto syncing is enabled. - if (prefs.autoUpdateTrack()) { - add(db.getTracks(manga).asRxSingle() - .subscribe({ trackList = it })) - } - - restartableLatestCache(LOAD_ACTIVE_CHAPTER, - { loadChapterObservable(chapter) }, - { view, _ -> view.onChapterReady(this.chapter) }, - { view, error -> view.onChapterError(error) }) - - if (savedState == null) { - loadChapter(chapter) + if (savedState != null) { + chapterId = savedState.getLong(::chapterId.name, -1) } } - 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) - } - + /** + * Called when the presenter is destroyed. It saves the current progress and cleans up + * references on the currently active chapters. + */ override fun onDestroy() { - loader.cleanup() - onChapterLeft() super.onDestroy() + val currentChapters = viewerChaptersRelay.value + if (currentChapters != null) { + currentChapters.unref() + saveChapterProgress(currentChapters.currChapter) + saveChapterHistory(currentChapters.currChapter) + } } /** - * Converts a chapter to a [ReaderChapter] if needed. + * Called when the presenter instance is being saved. It saves the currently active chapter + * id and the last page read. */ - private fun Chapter.toModel(): ReaderChapter { - if (this is ReaderChapter) return this - return ReaderChapter(this) + 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!!) + } } /** - * Returns an observable that loads the given chapter, discarding any previous work. - * - * @param chapter the now active chapter. + * 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 fun loadChapterObservable(chapter: ReaderChapter): Observable { - loader.restart() + fun onBackPressed() { + updateTrackLastChapterRead() + deletePendingChapters() + } + + /** + * Called when the activity is saved and not changing configurations. It updates the database + * to persist the current progress of the active chapter. + */ + fun onSaveInstanceStateNonConfigurationChange() { + val currentChapter = getCurrentChapter() ?: return + saveChapterProgress(currentChapter) + } + + /** + * Whether this presenter is initialized yet. + */ + fun needsInit(): Boolean { + return manga == null + } + + /** + * 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 + + this.manga = manga + if (chapterId == -1L) chapterId = initialChapterId + + val source = sourceManager.getOrStub(manga.source) + loader = ChapterLoader(downloadManager, manga, source) + + Observable.just(manga).subscribeLatestCache(ReaderActivity::setManga) + viewerChaptersRelay.subscribeLatestCache(ReaderActivity::setChapters) + isLoadingAdjacentChapterRelay.subscribeLatestCache(ReaderActivity::setProgressDialog) + + // 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) + } + + /** + * 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 getLoadObservable( + loader: ChapterLoader, + chapter: ReaderChapter + ): Observable { return loader.loadChapter(chapter) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { chapterLoaded = true } - } + .andThen(Observable.fromCallable { + val chapterPos = chapterList.indexOf(chapter) - /** - * 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. - */ - private fun getAdjacentChapters(chapter: ReaderChapter) { - // Keep only one subscription - adjacentChaptersSubscription?.let { remove(it) } + ViewerChapters(chapter, + chapterList.getOrNull(chapterPos - 1), + chapterList.getOrNull(chapterPos + 1)) + }) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { newChapters -> + val oldChapters = viewerChaptersRelay.value - 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) - }) - } + // Add new references first to avoid unnecessary recycling + newChapters.ref() + oldChapters?.unref() - /** - * 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). - */ - 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) - } - 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 - } + viewerChaptersRelay.call(newChapters) } - - 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") } /** - * 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 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 loadChapter(chapter: ReaderChapter, requestedPage: Int = 0) { - // Cleanup any append. - appenderSubscription?.let { remove(it) } + private fun loadNewChapter(chapter: ReaderChapter) { + val loader = loader ?: return - this.chapter = loadedChapters.getOrPut(chapter.id) { chapter } + Timber.d("Loading ${chapter.chapter.url}") - // 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 - - chapterLoaded = false - start(LOAD_ACTIVE_CHAPTER) - getAdjacentChapters(chapter) + activeChapterSubscription?.unsubscribe() + activeChapterSubscription = getLoadObservable(loader, chapter) + .toCompletable() + .onErrorComplete() + .subscribe() + .also(::add) } /** - * 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 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. */ - fun setActiveChapter(chapter: ReaderChapter) { - onChapterLeft() - this.chapter = chapter - nextChapter = null - prevChapter = null - getAdjacentChapters(chapter) + private fun loadAdjacent(chapter: ReaderChapter) { + val loader = loader ?: return + + 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 + }) } /** - * Appends the next chapter to the reader, if possible. + * 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. */ - 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() - }) - } - - /** - * Retries a page that failed to load due to network error or corruption. - * - * @param page the page that failed. - */ - 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) - } - } - - /** - * Called before loading another chapter or leaving the reader. It allows to do operations - * over the chapter read like saving progress - */ - 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() - } - - /** - * Called when the active page changes in the reader. - * - * @param page the active page - */ - 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) - } - } - - /** - * Delete selected chapter - * - * @param chapter chapter that is selected - * @param manga manga that belongs to chapter - */ - fun deleteChapter(chapter: ReaderChapter, manga: Manga) { - chapter.isDownloaded = false - chapter.pages?.forEach { it.status == Page.QUEUE } - downloadManager.deleteChapter(chapter, manga, source) - } - - /** - * Returns the chapter to be marked as last read in sync services or 0 if no update required. - */ - fun getTrackChapterToUpdate(): Int { - val trackList = trackList - if (chapter.pages == null || trackList == null || trackList.isEmpty()) - return 0 - - val prevChapter = 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 - - return if (trackList.any { lastChapterRead > it.last_chapter_read }) - lastChapterRead - else - 0 - } - - /** - * Starts the service that updates the last chapter read in sync services - */ - 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 - - // 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) }) - } - } - } - - /** - * Loads the next chapter. - * - * @return true if the next chapter is being loaded, false if there is no next chapter. - */ - fun loadNextChapter(): Boolean { - // Avoid skipping chapters. - if (!chapterLoaded) return true - - nextChapter?.let { - onChapterLeft() - loadChapter(it, 0) - return true - } - return false - } - - /** - * Loads the next chapter. - * - * @return true if the previous chapter is being loaded, false if there is no previous chapter. - */ - fun loadPreviousChapter(): Boolean { - // Avoid skipping chapters. - if (!chapterLoaded) return true - - prevChapter?.let { - onChapterLeft() - loadChapter(it, if (it.read) -1 else 0) - return true - } - return false - } - - /** - * Returns true if there's a next chapter. - */ - fun hasNextChapter(): Boolean { - return nextChapter != null - } - - /** - * Returns true if there's a previous chapter. - */ - fun hasPreviousChapter(): Boolean { - return prevChapter != null - } - - /** - * Updates the viewer for this manga. - * - * @param viewer the id of the viewer to set. - */ - fun updateMangaViewer(viewer: Int) { - manga.viewer = viewer - db.insertManga(manga).executeAsBlocking() - } - - /** - * Update cover with page file. - */ - 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) - } - } - - /** - * Save page to local storage. - */ - internal fun savePage(page: Page) { - if (page.status != Page.READY) + private fun preload(chapter: ReaderChapter) { + if (chapter.state != ReaderChapter.State.Wait && chapter.state !is ReaderChapter.State.Error) { return + } - // Used to show image notification. - val imageNotifier = SaveImageNotifier(context) + Timber.d("Preloading ${chapter.chapter.url}") - // Remove the notification if it already exists (user feedback). - imageNotifier.onClear() + 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) + } + + /** + * 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. + */ + fun onPageSelected(page: ReaderPage) { + val currentChapters = viewerChaptersRelay.value ?: return + + val selectedChapter = page.chapter + + // 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) + } + + if (selectedChapter != currentChapters.currChapter) { + Timber.d("Setting ${selectedChapter.chapter.url} as active") + onChapterChanged(currentChapters.currChapter, selectedChapter) + loadNewChapter(selectedChapter) + } + } + + /** + * Called when a chapter changed from [fromChapter] to [toChapter]. It updates [fromChapter] + * on the database. + */ + private fun onChapterChanged(fromChapter: ReaderChapter, toChapter: ReaderChapter) { + saveChapterProgress(fromChapter) + saveChapterHistory(fromChapter) + } + + /** + * Saves this [chapter] progress (last read page and whether it's read). + */ + private fun saveChapterProgress(chapter: ReaderChapter) { + db.updateChapterProgress(chapter.chapter).asRxCompletable() + .onErrorComplete() + .subscribeOn(Schedulers.io()) + .subscribe() + } + + /** + * Saves this [chapter] last read history. + */ + 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 from the activity to preload the next chapter. + */ + fun preloadNextChapter() { + val nextChapter = viewerChaptersRelay.value?.nextChapter ?: return + preload(nextChapter) + } + + /** + * Called from the activity to preload the previous chapter. + */ + fun preloadPreviousChapter() { + val prevChapter = viewerChaptersRelay.value?.prevChapter ?: return + preload(prevChapter) + } + + /** + * Called from the activity to load and set the next chapter as active. + */ + fun loadNextChapter() { + val nextChapter = viewerChaptersRelay.value?.nextChapter ?: return + loadAdjacent(nextChapter) + } + + /** + * Called from the activity to load and set the previous chapter as active. + */ + fun loadPreviousChapter() { + val prevChapter = viewerChaptersRelay.value?.prevChapter ?: return + loadAdjacent(prevChapter) + } + + /** + * Returns the currently active chapter. + */ + fun getCurrentChapter(): ReaderChapter? { + return viewerChaptersRelay.value?.currChapter + } + + /** + * 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 + } + + /** + * Updates the viewer position for the open manga. + */ + fun setMangaViewer(viewer: Int) { + val manga = manga ?: return + manga.viewer = viewer + // TODO custom put operation + db.insertManga(manga).executeAsBlocking() + + 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) + } + }) + } + + /** + * Saves the image of this [page] in the given [directory] and returns the file location. + */ + 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}" + + val destFile = File(directory, filename) + stream().use { input -> + destFile.outputStream().use { output -> + input.copyTo(output) + } + } + return destFile + } + + /** + * 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 saveImage(page: ReaderPage) { + if (page.status != Page.READY) return + val manga = manga ?: return + val context = Injekt.get() + + val notifier = SaveImageNotifier(context) + notifier.onClear() // Pictures directory. - val pictureDirectory = Environment.getExternalStorageDirectory().absolutePath + - File.separator + Environment.DIRECTORY_PICTURES + - File.separator + context.getString(R.string.app_name) + val destDir = File(Environment.getExternalStorageDirectory().absolutePath + + File.separator + Environment.DIRECTORY_PICTURES + + File.separator + "Tachiyomi") // 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) - } - - // 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) - - context.contentResolver.openInputStream(page.uri).use { input -> - destFile.outputStream().use { output -> - input.copyTo(output) - } - } - - DiskUtil.scanMedia(context, destFile) - - imageNotifier.onComplete(destFile) - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ - context.toast(R.string.picture_saved) - }, { error -> - Timber.e(error) - imageNotifier.onError(error.message) - }) + 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)) } + ) } + + /** + * 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 shareImage(page: ReaderPage) { + if (page.status != Page.READY) return + val manga = manga ?: return + val context = Injekt.get() + + 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 */ } + ) + } + + /** + * Sets the image of this [page] as cover and notifies the UI of the result. + */ + 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() + 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) } + ) + } + + /** + * Results of the set as cover feature. + */ + enum class SetAsCoverResult { + Success, AddToLibraryFirst, Error + } + + /** + * Results of the save image feature. + */ + sealed class SaveImageResult { + class Success(val file: File) : SaveImageResult() + class Error(val error: Throwable) : SaveImageResult() + } + + /** + * Starts the service that updates the last chapter read in sync services. This operation + * will run in a background thread and errors are ignored. + */ + private fun updateTrackLastChapterRead() { + if (!preferences.autoUpdateTrack()) return + val viewerChapters = viewerChaptersRelay.value ?: return + val manga = manga ?: return + + val currChapter = viewerChapters.currChapter.chapter + val prevChapter = viewerChapters.prevChapter?.chapter + + // 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 + + val trackManager = Injekt.get() + + 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() + } + + /** + * 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 + + // Return if the setting is disabled + val removeAfterReadSlots = preferences.removeAfterReadSlots() + if (removeAfterReadSlots == -1) return + + Completable + .fromCallable { + // Position of the read chapter + val position = chapterList.indexOf(chapter) + + // Retrieve chapter to delete according to preference + val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots) + if (chapterToDelete != null) { + downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga) + } + } + .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() + } + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSeekBar.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSeekBar.kt new file mode 100644 index 000000000..c429804eb --- /dev/null +++ b/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) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsDialog.kt deleted file mode 100644 index f40f47e2c..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsDialog.kt +++ /dev/null @@ -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() - - 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() - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt new file mode 100644 index 000000000..0a726eef3 --- /dev/null +++ b/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() + + 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) { + 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, offset: Int = 0) { + onItemSelectedListener = IgnoreFirstSpinnerListener { position -> + pref.set(position + offset) + } + setSelection(pref.getOrDefault() - offset, false) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt index ab690c819..87ed7ab16 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt +++ b/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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt new file mode 100644 index 000000000..2bc91b063 --- /dev/null +++ b/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") + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt new file mode 100644 index 000000000..d42efcef0 --- /dev/null +++ b/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> { + val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + + return file.listFiles() + .filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } + .sortedWith(Comparator { 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 { + return Observable.just(Page.READY) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt new file mode 100644 index 000000000..af7d96930 --- /dev/null +++ b/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() + + /** + * Returns an observable containing the pages found on this downloaded chapter. + */ + override fun getPages(): Observable> { + 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 { + return Observable.just(Page.READY) // TODO maybe check if file still exists? + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt new file mode 100644 index 000000000..76965b645 --- /dev/null +++ b/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> { + 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 { + return Observable.just(if (isRecycled) { + Page.ERROR + } else { + Page.READY + }) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt new file mode 100644 index 000000000..3a12bed60 --- /dev/null +++ b/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() + + /** + * 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> { + 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 { + 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()) + 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 { + + 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 { + return if (page.imageUrl.isNullOrEmpty()) + getImageUrl(page).flatMap { getCachedImage(it) } + else + getCachedImage(page) + } + + private fun HttpSource.getImageUrl(page: ReaderPage): Observable { + 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 { + 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 { + page.status = Page.DOWNLOAD_IMAGE + return fetchImage(page) + .doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) } + .map { page } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt new file mode 100644 index 000000000..38cac3817 --- /dev/null +++ b/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> + + /** + * 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 + + /** + * 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) {} + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt new file mode 100644 index 000000000..dcba332aa --- /dev/null +++ b/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> { + val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + + return archive.fileHeaders + .filter { !it.isDirectory && ImageUtil.isImage(it.fileNameString) { archive.getInputStream(it) } } + .sortedWith(Comparator { 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 { + 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 + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt new file mode 100644 index 000000000..3cd1b541e --- /dev/null +++ b/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> { + val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + + return zip.entries().toList() + .filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } + .sortedWith(Comparator { 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 { + return Observable.just(if (isRecycled) { + Page.ERROR + } else { + Page.READY + }) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ChapterTransition.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ChapterTransition.kt new file mode 100644 index 000000000..892eceab0 --- /dev/null +++ b/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})" + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt new file mode 100644 index 000000000..366ce2595 --- /dev/null +++ b/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? + 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) : State() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt new file mode 100644 index 000000000..34b415253 --- /dev/null +++ b/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 + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ViewerChapters.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ViewerChapters.kt new file mode 100644 index 000000000..1e950c6c7 --- /dev/null +++ b/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() + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/BaseViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/BaseViewer.kt new file mode 100644 index 000000000..7420db518 --- /dev/null +++ b/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 + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/GestureDetectorWithLongTap.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/GestureDetectorWithLongTap.kt new file mode 100644 index 000000000..d941d3bc4 --- /dev/null +++ b/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) { + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderProgressBar.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderProgressBar.kt new file mode 100644 index 000000000..e097c5063 --- /dev/null +++ b/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() + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/BaseReader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/BaseReader.kt deleted file mode 100644 index ed1d12638..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/BaseReader.kt +++ /dev/null @@ -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() - - /** - * List of pages added in the reader. It can contain pages from more than one chapter. - */ - var pages: MutableList = ArrayList() - private set - - /** - * Current visible position of [pages]. - */ - var currentPage: Int = 0 - protected set - - /** - * Region decoder class to use. - */ - lateinit var regionDecoderClass: Class - private set - - /** - * Bitmap decoder class to use. - */ - lateinit var bitmapDecoderClass: Class - 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 - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/PageDecodeErrorLayout.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/PageDecodeErrorLayout.kt deleted file mode 100644 index 559621932..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/PageDecodeErrorLayout.kt +++ /dev/null @@ -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 - } - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/OnChapterBoundariesOutListener.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/OnChapterBoundariesOutListener.kt deleted file mode 100644 index 150711349..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/OnChapterBoundariesOutListener.kt +++ /dev/null @@ -1,6 +0,0 @@ -package eu.kanade.tachiyomi.ui.reader.viewer.pager - -interface OnChapterBoundariesOutListener { - fun onFirstPageOutEvent() - fun onLastPageOutEvent() -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PageView.kt deleted file mode 100644 index 7143b8d0c..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PageView.kt +++ /dev/null @@ -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()) - 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) - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.java b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.java deleted file mode 100644 index a3186babb..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.java +++ /dev/null @@ -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 onPageChanged); - void clearOnPageChangeListeners(); -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.kt new file mode 100644 index 000000000..ceaf610f1 --- /dev/null +++ b/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 + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerButton.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerButton.kt new file mode 100644 index 000000000..030ed80d0 --- /dev/null +++ b/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 + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt new file mode 100644 index 000000000..c5ebabc53 --- /dev/null +++ b/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 Preference.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 + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt new file mode 100644 index 000000000..176891d7d --- /dev/null +++ b/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() } + .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 { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + onImageDecodeError() + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + onImageDecoded() + return false + } + }) + .into(this) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReader.kt deleted file mode 100644 index e35ba8f8a..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReader.kt +++ /dev/null @@ -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 - } - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReaderAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReaderAdapter.kt deleted file mode 100644 index c3c820f09..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerReaderAdapter.kt +++ /dev/null @@ -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 = 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 - } - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt new file mode 100644 index 000000000..a763f0f65 --- /dev/null +++ b/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) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt new file mode 100644 index 000000000..33ac650f8 --- /dev/null +++ b/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 + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt new file mode 100644 index 000000000..d03e822b0 --- /dev/null +++ b/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 = 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() + + // 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 + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewers.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewers.kt new file mode 100644 index 000000000..a79f85814 --- /dev/null +++ b/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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/horizontal/HorizontalPager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/horizontal/HorizontalPager.kt deleted file mode 100644 index e359b7fbf..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/horizontal/HorizontalPager.kt +++ /dev/null @@ -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) { - addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() { - override fun onPageSelected(position: Int) { - func.call(position) - } - }) - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/horizontal/LeftToRightReader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/horizontal/LeftToRightReader.kt deleted file mode 100644 index 28d170deb..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/horizontal/LeftToRightReader.kt +++ /dev/null @@ -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) } - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/horizontal/RightToLeftReader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/horizontal/RightToLeftReader.kt deleted file mode 100644 index ab866cbd6..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/horizontal/RightToLeftReader.kt +++ /dev/null @@ -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() - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/vertical/VerticalPager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/vertical/VerticalPager.kt deleted file mode 100644 index b39ffd9aa..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/vertical/VerticalPager.kt +++ /dev/null @@ -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) { - addOnPageChangeListener(object : VerticalViewPagerImpl.SimpleOnPageChangeListener() { - override fun onPageSelected(position: Int) { - func.call(position) - } - }) - } - - companion object { - - private val SWIPE_TOLERANCE = 0.25f - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/vertical/VerticalReader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/vertical/VerticalReader.kt deleted file mode 100644 index 028e9b187..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/vertical/VerticalReader.kt +++ /dev/null @@ -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) } - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/vertical/VerticalViewPagerImpl.java b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/vertical/VerticalViewPagerImpl.java deleted file mode 100644 index d9174ff3d..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/vertical/VerticalViewPagerImpl.java +++ /dev/null @@ -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. - * - *

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.

- * - *

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. - * - *

For more information about how to use ViewPager, read Creating Swipe Views with - * Tabs.

- * - *

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 COMPARATOR = new Comparator(){ - @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 mItems = new ArrayList(); - 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 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 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. - * - *

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.

- */ - 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; iComponents 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.

- * - * @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. - * - *

Note: 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.

- * - * @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. - * - *

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.

- * - *

You should keep this limit low, especially if your pages have complex layouts. - * This setting defaults to 1.

- * - * @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(); - } 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 CREATOR - = ParcelableCompat.newCreator(new ParcelableCompatCreatorCallbacks() { - @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 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 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. - * - *

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. - * - *

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 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 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 { - @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; - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt index 6871e6bab..e215646c1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt +++ b/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() { +class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter() { /** - * Pages stored in the adapter. + * List of currently set items. */ - var pages: List? = null + var items: List = 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() + + // 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 a page given the position. - * - * @param position the position of the page. - * @return the page. + * Returns the view type for the item at the given [position]. */ - fun getItem(position: Int): Page { - return pages!![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}") + } } /** - * 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. + * Creates a new view holder for an item with the given [viewType]. */ - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WebtoonHolder { - val v = parent.inflate(R.layout.reader_webtoon_item) - return WebtoonHolder(v, this) + 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") + } } /** - * Binds a holder with a new position. - * - * @param holder the holder to bind. - * @param position the position to bind. + * Binds an existing view [holder] with the item at the given [position]. */ - override fun onBindViewHolder(holder: WebtoonHolder, position: Int) { - val page = getItem(position) - holder.onSetValues(page) + 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) + } } /** - * Recycles the view holder. - * - * @param holder the holder to recycle. + * Recycles an existing view [holder] before adding it to the view pool. */ - override fun onViewRecycled(holder: WebtoonHolder) { - holder.onRecycle() + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + when (holder) { + is WebtoonPageHolder -> holder.recycle() + is WebtoonTransitionHolder -> holder.recycle() + } + } + + /** + * Diff util callback used to dispatch delta updates instead of full dataset changes. + */ + private class Callback( + private val oldItems: List, + private val newItems: List + ) : 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 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonBaseHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonBaseHolder.kt new file mode 100644 index 000000000..293127cb3 --- /dev/null +++ b/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) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt new file mode 100644 index 000000000..f575e4ac4 --- /dev/null +++ b/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 Preference.register( + valueAssignment: (T) -> Unit, + onChanged: (T) -> Unit = {} + ) { + asObservable() + .doOnNext(valueAssignment) + .skip(1) + .distinctUntilChanged() + .doOnNext(onChanged) + .subscribe() + .addTo(subscriptions) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonFrame.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonFrame.kt new file mode 100644 index 000000000..955dc898e --- /dev/null +++ b/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 + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt deleted file mode 100644 index 34488d65e..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt +++ /dev/null @@ -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()) - 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 -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt new file mode 100644 index 000000000..c9cd0712c --- /dev/null +++ b/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) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt new file mode 100644 index 000000000..c6c187662 --- /dev/null +++ b/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() } + .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 { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + onImageDecodeError() + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + onImageDecoded() + return false + } + }) + .into(this) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt deleted file mode 100644 index 3ac0c5085..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt +++ /dev/null @@ -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) - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt new file mode 100644 index 000000000..f838e62ed --- /dev/null +++ b/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 + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonSubsamplingImageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonSubsamplingImageView.kt new file mode 100644 index 000000000..692c0596b --- /dev/null +++ b/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 + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt new file mode 100644 index 000000000..390615503 --- /dev/null +++ b/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) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt new file mode 100644 index 000000000..2bb5f1543 --- /dev/null +++ b/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 + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt index 13dce8177..bcf418761 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt +++ b/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) { - 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) { + 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 + } + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt index ae9f739f6..66dd44597 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt +++ b/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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt index 75619dd78..d89b3ce5a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt +++ b/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 } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt index eb90b38a1..edff38614 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt +++ b/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) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/EpubFile.kt b/app/src/main/java/eu/kanade/tachiyomi/util/EpubFile.kt new file mode 100644 index 000000000..f1c81b5b8 --- /dev/null +++ b/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 { + 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 { + 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, hrefs: Map): List { + 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): Map { + 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 + } + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ImageUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ImageUtil.kt new file mode 100644 index 000000000..dd9721957 --- /dev/null +++ b/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") + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/RarContentProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/util/RarContentProvider.kt deleted file mode 100644 index 12ad9706f..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/util/RarContentProvider.kt +++ /dev/null @@ -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?, p2: String?, p3: Array?, 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?): Int { - throw UnsupportedOperationException("not implemented") - } - - override fun delete(p0: Uri?, p1: String?, p2: Array?): Int { - throw UnsupportedOperationException("not implemented") - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/RxExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/RxExtensions.kt index 1f4933552..8a0c965ff 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/RxExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/RxExtensions.kt @@ -10,4 +10,8 @@ operator fun CompositeSubscription.plusAssign(subscription: Subscription) = add( fun Observable.combineLatest(o2: Observable, combineFn: (T, U) -> R): Observable { return Observable.combineLatest(this, o2, combineFn) -} \ No newline at end of file +} + +fun Subscription.addTo(subscriptions: CompositeSubscription) { + subscriptions.add(this) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ZipContentProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ZipContentProvider.kt deleted file mode 100644 index 737007720..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ZipContentProvider.kt +++ /dev/null @@ -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?, p2: String?, p3: Array?, 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?): Int { - throw UnsupportedOperationException("not implemented") - } - - override fun delete(p0: Uri?, p1: String?, p2: Array?): Int { - throw UnsupportedOperationException("not implemented") - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/ViewPagerAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/ViewPagerAdapter.kt index c2c21a66b..3effd64ec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/ViewPagerAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/ViewPagerAdapter.kt @@ -27,4 +27,8 @@ abstract class ViewPagerAdapter : PagerAdapter() { return view === obj } -} \ No newline at end of file + interface PositionableView { + val item: Any + } + +} diff --git a/app/src/main/res/drawable/ic_image_black_24dp.xml b/app/src/main/res/drawable/ic_image_black_24dp.xml new file mode 100644 index 000000000..b2018595e --- /dev/null +++ b/app/src/main/res/drawable/ic_image_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout-land/reader_color_filter_sheet.xml b/app/src/main/res/layout-land/reader_color_filter_sheet.xml new file mode 100644 index 000000000..ba4d45e40 --- /dev/null +++ b/app/src/main/res/layout-land/reader_color_filter_sheet.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/reader_activity.xml b/app/src/main/res/layout/reader_activity.xml index 88e479e51..777bdaa78 100644 --- a/app/src/main/res/layout/reader_activity.xml +++ b/app/src/main/res/layout/reader_activity.xml @@ -11,17 +11,18 @@ android:layout_height="match_parent"> + android:layout_width="56dp" + android:layout_height="56dp" + android:layout_gravity="center" + android:visibility="gone" + 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" /> + android:textSize="15sp" + android:clickable="true" + tools:text="1"/> - + android:layout_height="match_parent" + android:layout_weight="1" /> + android:textSize="15sp" + android:clickable="true" + tools:text="15"/> @@ -113,4 +117,4 @@ android:layout_height="match_parent" android:visibility="gone"/> - \ No newline at end of file + diff --git a/app/src/main/res/layout/reader_color_filter.xml b/app/src/main/res/layout/reader_color_filter.xml new file mode 100644 index 000000000..736ee590e --- /dev/null +++ b/app/src/main/res/layout/reader_color_filter.xml @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/reader_color_filter_sheet.xml b/app/src/main/res/layout/reader_color_filter_sheet.xml new file mode 100644 index 000000000..618a8a77f --- /dev/null +++ b/app/src/main/res/layout/reader_color_filter_sheet.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/reader_custom_filter_dialog.xml b/app/src/main/res/layout/reader_custom_filter_dialog.xml deleted file mode 100644 index 0f5483dea..000000000 --- a/app/src/main/res/layout/reader_custom_filter_dialog.xml +++ /dev/null @@ -1,263 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/reader_page_decode_error.xml b/app/src/main/res/layout/reader_page_decode_error.xml deleted file mode 100644 index e623788c5..000000000 --- a/app/src/main/res/layout/reader_page_decode_error.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - -