New reader (#1550)

* Delete old reader

* Add utility methods

* Update dependencies

* Add new reader

* Update tracking services. Extract transition strings into resources

* Restore delete read chapters

* Documentation and some minor changes

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

* Update subsampling. New changes allow to parse magic numbers and decode tiles with a single stream. Drop support for custom image decoders. Other minor fixes
This commit is contained in:
inorichi 2018-09-01 17:12:59 +02:00 committed by GitHub
parent 7c99ae1b3b
commit 18f89cc341
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
105 changed files with 6918 additions and 7247 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,180 @@
package eu.kanade.tachiyomi.data.download
import android.content.Context
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.Gson
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import uy.kohesive.injekt.injectLazy
/**
* Class used to keep a list of chapters for future deletion.
*
* @param context the application context.
*/
class DownloadPendingDeleter(context: Context) {
/**
* Gson instance to encode and decode chapters.
*/
private val gson by injectLazy<Gson>()
/**
* Preferences used to store the list of chapters to delete.
*/
private val prefs = context.getSharedPreferences("chapters_to_delete", Context.MODE_PRIVATE)
/**
* Last added chapter, used to avoid decoding from the preference too often.
*/
private var lastAddedEntry: Entry? = null
/**
* Adds a list of chapters for future deletion.
*
* @param chapters the chapters to be deleted.
* @param manga the manga of the chapters.
*/
@Synchronized
fun addChapters(chapters: List<Chapter>, manga: Manga) {
val lastEntry = lastAddedEntry
val newEntry = if (lastEntry != null && lastEntry.manga.id == manga.id) {
// Append new chapters
val newChapters = lastEntry.chapters.addUniqueById(chapters)
// If no chapters were added, do nothing
if (newChapters.size == lastEntry.chapters.size) return
// Last entry matches the manga, reuse it to avoid decoding json from preferences
lastEntry.copy(chapters = newChapters)
} else {
val existingEntry = prefs.getString(manga.id!!.toString(), null)
if (existingEntry != null) {
// Existing entry found on preferences, decode json and add the new chapter
val savedEntry = gson.fromJson<Entry>(existingEntry)
// Append new chapters
val newChapters = savedEntry.chapters.addUniqueById(chapters)
// If no chapters were added, do nothing
if (newChapters.size == savedEntry.chapters.size) return
savedEntry.copy(chapters = newChapters)
} else {
// No entry has been found yet, create a new one
Entry(chapters.map { it.toEntry() }, manga.toEntry())
}
}
// Save current state
val json = gson.toJson(newEntry)
prefs.edit().putString(newEntry.manga.id.toString(), json).apply()
lastAddedEntry = newEntry
}
/**
* Returns the list of chapters to be deleted grouped by its manga.
*
* Note: the returned list of manga and chapters only contain basic information needed by the
* downloader, so don't use them for anything else.
*/
@Synchronized
fun getPendingChapters(): Map<Manga, List<Chapter>> {
val entries = decodeAll()
prefs.edit().clear().apply()
lastAddedEntry = null
return entries.associate { entry ->
entry.manga.toModel() to entry.chapters.map { it.toModel() }
}
}
/**
* Decodes all the chapters from preferences.
*/
private fun decodeAll(): List<Entry> {
return prefs.all.values.mapNotNull { rawEntry ->
try {
(rawEntry as? String)?.let { gson.fromJson<Entry>(it) }
} catch (e: Exception) {
null
}
}
}
/**
* Returns a copy of chapter entries ensuring no duplicates by chapter id.
*/
private fun List<ChapterEntry>.addUniqueById(chapters: List<Chapter>): List<ChapterEntry> {
val newList = toMutableList()
for (chapter in chapters) {
if (none { it.id == chapter.id }) {
newList.add(chapter.toEntry())
}
}
return newList
}
/**
* Class used to save an entry of chapters with their manga into preferences.
*/
private data class Entry(
val chapters: List<ChapterEntry>,
val manga: MangaEntry
)
/**
* Class used to save an entry for a chapter into preferences.
*/
private data class ChapterEntry(
val id: Long,
val url: String,
val name: String
)
/**
* Class used to save an entry for a manga into preferences.
*/
private data class MangaEntry(
val id: Long,
val url: String,
val title: String,
val source: Long
)
/**
* Returns a manga entry from a manga model.
*/
private fun Manga.toEntry(): MangaEntry {
return MangaEntry(id!!, url, title, source)
}
/**
* Returns a chapter entry from a chapter model.
*/
private fun Chapter.toEntry(): ChapterEntry {
return ChapterEntry(id!!, url, name)
}
/**
* Returns a manga model from a manga entry.
*/
private fun MangaEntry.toModel(): Manga {
return Manga.create(url, title, source).also {
it.id = id
}
}
/**
* Returns a chapter model from a chapter entry.
*/
private fun ChapterEntry.toModel(): Chapter {
return Chapter.create().also {
it.id = id
it.url = url
it.name = name
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.data.glide
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.signature.ObjectKey
import java.io.IOException
import java.io.InputStream
class PassthroughModelLoader : ModelLoader<InputStream, InputStream> {
override fun buildLoadData(
model: InputStream,
width: Int,
height: Int,
options: Options
): ModelLoader.LoadData<InputStream>? {
return ModelLoader.LoadData(ObjectKey(model), Fetcher(model))
}
override fun handles(model: InputStream): Boolean {
return true
}
class Fetcher(private val stream: InputStream) : DataFetcher<InputStream> {
override fun getDataClass(): Class<InputStream> {
return InputStream::class.java
}
override fun cleanup() {
try {
stream.close()
} catch (e: IOException) {
// Do nothing
}
}
override fun getDataSource(): DataSource {
return DataSource.LOCAL
}
override fun cancel() {
// Do nothing
}
override fun loadData(
priority: Priority,
callback: DataFetcher.DataCallback<in InputStream>
) {
callback.onDataReady(stream)
}
}
/**
* Factory class for creating [PassthroughModelLoader] instances.
*/
class Factory : ModelLoaderFactory<InputStream, InputStream> {
override fun build(
multiFactory: MultiModelLoaderFactory
): ModelLoader<InputStream, InputStream> {
return PassthroughModelLoader()
}
override fun teardown() {}
}
}

View File

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

View File

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

View File

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

View File

@ -1,24 +1,22 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.source
import android.content.Context import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.* import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.util.ChapterRecognition import eu.kanade.tachiyomi.util.ChapterRecognition
import eu.kanade.tachiyomi.util.DiskUtil import eu.kanade.tachiyomi.util.DiskUtil
import eu.kanade.tachiyomi.util.RarContentProvider import eu.kanade.tachiyomi.util.EpubFile
import eu.kanade.tachiyomi.util.ZipContentProvider import eu.kanade.tachiyomi.util.ImageUtil
import junrar.Archive import junrar.Archive
import junrar.rarfile.FileHeader import junrar.rarfile.FileHeader
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import rx.Observable import rx.Observable
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream import java.io.InputStream
import java.util.* import java.util.Comparator
import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
@ -107,18 +105,14 @@ class LocalSource(private val context: Context) : CatalogueSource {
if (thumbnail_url == null) { if (thumbnail_url == null) {
val chapters = fetchChapterList(this).toBlocking().first() val chapters = fetchChapterList(this).toBlocking().first()
if (chapters.isNotEmpty()) { if (chapters.isNotEmpty()) {
val uri = fetchPageList(chapters.last()).toBlocking().first().firstOrNull()?.uri
if (uri != null) {
val input = context.contentResolver.openInputStream(uri)
try { try {
val dest = updateCover(context, this, input) val dest = updateCover(chapters.last(), this)
thumbnail_url = dest?.absolutePath thumbnail_url = dest?.absolutePath
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
} }
} }
} }
}
initialized = true initialized = true
} }
@ -135,7 +129,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
val chapters = getBaseDirectories(context) val chapters = getBaseDirectories(context)
.mapNotNull { File(it, manga.url).listFiles()?.toList() } .mapNotNull { File(it, manga.url).listFiles()?.toList() }
.flatten() .flatten()
.filter { it.isDirectory || isSupportedFormat(it.extension) } .filter { it.isDirectory || isSupportedFile(it.extension) }
.map { chapterFile -> .map { chapterFile ->
SChapter.create().apply { SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}" url = "${manga.url}/${chapterFile.name}"
@ -150,7 +144,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
ChapterRecognition.parseChapterNumber(this, manga) ChapterRecognition.parseChapterNumber(this, manga)
} }
} }
.sortedWith(Comparator<SChapter> { c1, c2 -> .sortedWith(Comparator { c1, c2 ->
val c = c2.chapter_number.compareTo(c1.chapter_number) val c = c2.chapter_number.compareTo(c1.chapter_number)
if (c == 0) comparator.compare(c2.name, c1.name) else c if (c == 0) comparator.compare(c2.name, c1.name) else c
}) })
@ -159,160 +153,90 @@ class LocalSource(private val context: Context) : CatalogueSource {
} }
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.error(Exception("Unused"))
}
private fun isSupportedFile(extension: String): Boolean {
return extension.toLowerCase() in setOf("zip", "rar", "cbr", "cbz", "epub")
}
fun getFormat(chapter: SChapter): Format {
val baseDirs = getBaseDirectories(context) val baseDirs = getBaseDirectories(context)
for (dir in baseDirs) { for (dir in baseDirs) {
val chapFile = File(dir, chapter.url) val chapFile = File(dir, chapter.url)
if (!chapFile.exists()) continue if (!chapFile.exists()) continue
return Observable.just(getLoader(chapFile).load()) return getFormat(chapFile)
}
throw Exception("Chapter not found")
} }
return Observable.error(Exception("Chapter not found")) private fun getFormat(file: File): Format {
}
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 {
val extension = file.extension val extension = file.extension
return if (file.isDirectory) { return if (file.isDirectory) {
DirectoryLoader(file) Format.Directory(file)
} else if (extension.equals("zip", true) || extension.equals("cbz", true)) { } else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
ZipLoader(file) Format.Zip(file)
} else if (extension.equals("epub", true)) {
EpubLoader(file)
} else if (extension.equals("rar", true) || extension.equals("cbr", true)) { } else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
RarLoader(file) Format.Rar(file)
} else if (extension.equals("epub", true)) {
Format.Epub(file)
} else { } else {
throw Exception("Invalid chapter format") throw Exception("Invalid chapter format")
} }
} }
private fun updateCover(chapter: SChapter, manga: SManga): File? {
val format = getFormat(chapter)
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return when (format) {
is Format.Directory -> {
val entry = format.file.listFiles()
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.find { !it.isDirectory && ImageUtil.isImage(it.name, { FileInputStream(it) }) }
entry?.let { updateCover(context, manga, it.inputStream())}
}
is Format.Zip -> {
ZipFile(format.file).use { zip ->
val entry = zip.entries().toList()
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.find { !it.isDirectory && ImageUtil.isImage(it.name, { zip.getInputStream(it) }) }
entry?.let { updateCover(context, manga, zip.getInputStream(it) )}
}
}
is Format.Rar -> {
Archive(format.file).use { archive ->
val entry = archive.fileHeaders
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
.find { !it.isDirectory && ImageUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
entry?.let { updateCover(context, manga, archive.getInputStream(it) )}
}
}
is Format.Epub -> {
EpubFile(format.file).use { epub ->
val entry = epub.getImagesFromPages()
.firstOrNull()
?.let { epub.getEntry(it) }
entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
}
}
}
}
private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true)) private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true))
override fun getFilterList() = FilterList(OrderBy()) override fun getFilterList() = FilterList(OrderBy())
interface Loader { sealed class Format {
fun load(): List<Page> data class Directory(val file: File) : Format()
data class Zip(val file: File) : Format()
data class Rar(val file: File): Format()
data class Epub(val file: File) : Format()
} }
class DirectoryLoader(val file: File) : Loader {
override fun load(): List<Page> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return file.listFiles()
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { FileInputStream(it) }) }
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.map { Uri.fromFile(it) }
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
}
}
class ZipLoader(val file: File) : Loader {
override fun load(): List<Page> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return ZipFile(file).use { zip ->
zip.entries().toList()
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { zip.getInputStream(it) }) }
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/${it.name}") }
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
}
}
}
class RarLoader(val file: File) : Loader {
override fun load(): List<Page> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return Archive(file).use { archive ->
archive.fileHeaders
.filter { !it.isDirectory && DiskUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
.map { Uri.parse("content://${RarContentProvider.PROVIDER}${file.absolutePath}!-/${it.fileNameString}") }
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
}
}
}
class EpubLoader(val file: File) : Loader {
override fun load(): List<Page> {
ZipFile(file).use { zip ->
val allEntries = zip.entries().toList()
val ref = getPackageHref(zip)
val doc = getPackageDocument(zip, ref)
val pages = getPagesFromDocument(doc)
val hrefs = getHrefMap(ref, allEntries.map { it.name })
return getImagesFromPages(zip, pages, hrefs)
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/$it") }
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
}
}
/**
* Returns the path to the package document.
*/
private fun getPackageHref(zip: ZipFile): String {
val meta = zip.getEntry("META-INF/container.xml")
if (meta != null) {
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
if (path != null) {
return path
}
}
return "OEBPS/content.opf"
}
/**
* Returns the package document where all the files are listed.
*/
private fun getPackageDocument(zip: ZipFile, ref: String): Document {
val entry = zip.getEntry(ref)
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
}
/**
* Returns all the pages from the epub.
*/
private fun getPagesFromDocument(document: Document): List<String> {
val pages = document.select("manifest > item")
.filter { "application/xhtml+xml" == it.attr("media-type") }
.associateBy { it.attr("id") }
val spine = document.select("spine > itemref").map { it.attr("idref") }
return spine.mapNotNull { pages[it] }.map { it.attr("href") }
}
/**
* Returns all the images contained in every page from the epub.
*/
private fun getImagesFromPages(zip: ZipFile, pages: List<String>, hrefs: Map<String, String>): List<String> {
return pages.map { page ->
val entry = zip.getEntry(hrefs[page])
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
document.getElementsByTag("img").mapNotNull { hrefs[it.attr("src")] }
}.flatten()
}
/**
* Returns a map with a relative url as key and abolute url as path.
*/
private fun getHrefMap(packageHref: String, entries: List<String>): Map<String, String> {
val lastSlashPos = packageHref.lastIndexOf('/')
if (lastSlashPos < 0) {
return entries.associateBy { it }
}
return entries.associateBy { entry ->
if (entry.isNotBlank() && entry.length > lastSlashPos) {
entry.substring(lastSlashPos + 1)
} else {
entry
}
}
}
}
} }

View File

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

View File

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

View File

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

View File

@ -0,0 +1,36 @@
package eu.kanade.tachiyomi.ui.reader
import eu.kanade.tachiyomi.data.database.models.Chapter
/**
* Load strategy using the source order. This is the default ordering.
*/
class ChapterLoadBySource {
fun get(allChapters: List<Chapter>): List<Chapter> {
return allChapters.sortedByDescending { it.source_order }
}
}
/**
* Load strategy using unique chapter numbers with same scanlator preference.
*/
class ChapterLoadByNumber {
fun get(allChapters: List<Chapter>, selectedChapter: Chapter): List<Chapter> {
val chapters = mutableListOf<Chapter>()
val chaptersByNumber = allChapters.groupBy { it.chapter_number }
for ((number, chaptersForNumber) in chaptersByNumber) {
val preferredChapter = when {
// Make sure the selected chapter is always present
number == selectedChapter.chapter_number -> selectedChapter
// If there is only one chapter for this number, use it
chaptersForNumber.size == 1 -> chaptersForNumber.first()
// Prefer a chapter of the same scanlator as the selected
else -> chaptersForNumber.find { it.scanlator == selectedChapter.scanlator }
?: chaptersForNumber.first()
}
chapters.add(preferredChapter)
}
return chapters.sortedBy { it.chapter_number }
}
}

View File

@ -1,140 +0,0 @@
package eu.kanade.tachiyomi.ui.reader
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.fetchImageFromCacheThenNet
import eu.kanade.tachiyomi.source.online.fetchPageListFromCacheThenNet
import eu.kanade.tachiyomi.util.plusAssign
import rx.Observable
import rx.schedulers.Schedulers
import rx.subscriptions.CompositeSubscription
import timber.log.Timber
import java.util.concurrent.PriorityBlockingQueue
import java.util.concurrent.atomic.AtomicInteger
class ChapterLoader(
private val downloadManager: DownloadManager,
private val manga: Manga,
private val source: Source
) {
private val queue = PriorityBlockingQueue<PriorityPage>()
private val subscriptions = CompositeSubscription()
fun init() {
prepareOnlineReading()
}
fun restart() {
cleanup()
init()
}
fun cleanup() {
subscriptions.clear()
queue.clear()
}
private fun prepareOnlineReading() {
if (source !is HttpSource) return
subscriptions += Observable.defer { Observable.just(queue.take().page) }
.filter { it.status == Page.QUEUE }
.concatMap { source.fetchImageFromCacheThenNet(it) }
.repeat()
.subscribeOn(Schedulers.io())
.subscribe({
}, { error ->
if (error !is InterruptedException) {
Timber.e(error)
}
})
}
fun loadChapter(chapter: ReaderChapter) = Observable.just(chapter)
.flatMap {
if (chapter.pages == null)
retrievePageList(chapter)
else
Observable.just(chapter.pages!!)
}
.doOnNext { pages ->
if (pages.isEmpty()) {
throw Exception("Page list is empty")
}
// Now that the number of pages is known, fix the requested page if the last one
// was requested.
if (chapter.requestedPage == -1) {
chapter.requestedPage = pages.lastIndex
}
loadPages(chapter)
}
.map { chapter }
private fun retrievePageList(chapter: ReaderChapter) = Observable.just(chapter)
.flatMap {
// Check if the chapter is downloaded.
chapter.isDownloaded = downloadManager.isChapterDownloaded(chapter, manga, true)
if (chapter.isDownloaded) {
// Fetch the page list from disk.
downloadManager.buildPageList(source, manga, chapter)
} else {
(source as? HttpSource)?.fetchPageListFromCacheThenNet(chapter)
?: source.fetchPageList(chapter)
}
}
.doOnNext { pages ->
chapter.pages = pages
pages.forEach { it.chapter = chapter }
}
private fun loadPages(chapter: ReaderChapter) {
if (!chapter.isDownloaded) {
loadOnlinePages(chapter)
}
}
private fun loadOnlinePages(chapter: ReaderChapter) {
chapter.pages?.let { pages ->
val startPage = chapter.requestedPage
val pagesToLoad = if (startPage == 0)
pages
else
pages.drop(startPage)
pagesToLoad.forEach { queue.offer(PriorityPage(it, 0)) }
}
}
fun loadPriorizedPage(page: Page) {
queue.offer(PriorityPage(page, 1))
}
fun retryPage(page: Page) {
queue.offer(PriorityPage(page, 2))
}
private data class PriorityPage(val page: Page, val priority: Int): Comparable<PriorityPage> {
companion object {
private val idGenerator = AtomicInteger()
}
private val identifier = idGenerator.incrementAndGet()
override fun compareTo(other: PriorityPage): Int {
val p = other.priority.compareTo(priority)
return if (p != 0) p else identifier.compareTo(other.identifier)
}
}
}

View File

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

View File

@ -1,13 +0,0 @@
package eu.kanade.tachiyomi.ui.reader
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.source.model.Page
class ReaderChapter(c: Chapter) : Chapter by c {
@Transient var pages: List<Page>? = null
var isDownloaded: Boolean = false
var requestedPage: Int = 0
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,119 +0,0 @@
package eu.kanade.tachiyomi.ui.reader
import android.app.Dialog
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.view.View
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.util.plusAssign
import eu.kanade.tachiyomi.util.visibleIf
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
import kotlinx.android.synthetic.main.reader_settings_dialog.view.*
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.injectLazy
import java.util.concurrent.TimeUnit.MILLISECONDS
class ReaderSettingsDialog : DialogFragment() {
private val preferences by injectLazy<PreferencesHelper>()
private lateinit var subscriptions: CompositeSubscription
override fun onCreateDialog(savedState: Bundle?): Dialog {
val dialog = MaterialDialog.Builder(activity!!)
.title(R.string.label_settings)
.customView(R.layout.reader_settings_dialog, true)
.positiveText(android.R.string.ok)
.build()
subscriptions = CompositeSubscription()
onViewCreated(dialog.view, savedState)
return dialog
}
override fun onViewCreated(view: View, savedState: Bundle?) = with(view) {
viewer.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
subscriptions += Observable.timer(250, MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe {
val readerActivity = activity as? ReaderActivity
if (readerActivity != null) {
readerActivity.presenter.updateMangaViewer(position)
readerActivity.recreate()
}
}
}
viewer.setSelection((activity as ReaderActivity).presenter.manga.viewer, false)
rotation_mode.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
subscriptions += Observable.timer(250, MILLISECONDS)
.subscribe {
preferences.rotation().set(position + 1)
}
}
rotation_mode.setSelection(preferences.rotation().getOrDefault() - 1, false)
scale_type.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
preferences.imageScaleType().set(position + 1)
}
scale_type.setSelection(preferences.imageScaleType().getOrDefault() - 1, false)
zoom_start.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
preferences.zoomStart().set(position + 1)
}
zoom_start.setSelection(preferences.zoomStart().getOrDefault() - 1, false)
image_decoder.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
preferences.imageDecoder().set(position)
}
image_decoder.setSelection(preferences.imageDecoder().getOrDefault(), false)
background_color.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
preferences.readerTheme().set(position)
}
background_color.setSelection(preferences.readerTheme().getOrDefault(), false)
show_page_number.isChecked = preferences.showPageNumber().getOrDefault()
show_page_number.setOnCheckedChangeListener { _, isChecked ->
preferences.showPageNumber().set(isChecked)
}
fullscreen.isChecked = preferences.fullscreen().getOrDefault()
fullscreen.setOnCheckedChangeListener { _, isChecked ->
preferences.fullscreen().set(isChecked)
}
crop_borders.isChecked = preferences.cropBorders().getOrDefault()
crop_borders.setOnCheckedChangeListener { _, isChecked ->
preferences.cropBorders().set(isChecked)
}
crop_borders_webtoon.isChecked = preferences.cropBordersWebtoon().getOrDefault()
crop_borders_webtoon.setOnCheckedChangeListener { _, isChecked ->
preferences.cropBordersWebtoon().set(isChecked)
}
val readerActivity = activity as? ReaderActivity
val isWebtoonViewer = if (readerActivity != null) {
val mangaViewer = readerActivity.presenter.manga.viewer
val viewer = if (mangaViewer == 0) preferences.defaultViewer() else mangaViewer
viewer == ReaderActivity.WEBTOON
} else {
false
}
crop_borders.visibleIf { !isWebtoonViewer }
crop_borders_webtoon.visibleIf { isWebtoonViewer }
}
override fun onDestroyView() {
subscriptions.unsubscribe()
super.onDestroyView()
}
}

View File

@ -0,0 +1,104 @@
package eu.kanade.tachiyomi.ui.reader
import android.os.Bundle
import android.support.design.widget.BottomSheetDialog
import android.support.v4.widget.NestedScrollView
import android.widget.CompoundButton
import android.widget.Spinner
import com.f2prateek.rx.preferences.Preference
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
import eu.kanade.tachiyomi.util.visible
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
import kotlinx.android.synthetic.main.reader_settings_sheet.*
import uy.kohesive.injekt.injectLazy
/**
* Sheet to show reader and viewer preferences.
*/
class ReaderSettingsSheet(private val activity: ReaderActivity) : BottomSheetDialog(activity) {
/**
* Preferences helper.
*/
private val preferences by injectLazy<PreferencesHelper>()
init {
// Use activity theme for this layout
val view = activity.layoutInflater.inflate(R.layout.reader_settings_sheet, null)
val scroll = NestedScrollView(activity)
scroll.addView(view)
setContentView(scroll)
}
/**
* Called when the sheet is created. It initializes the listeners and values of the preferences.
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initGeneralPreferences()
when (activity.viewer) {
is PagerViewer -> initPagerPreferences()
is WebtoonViewer -> initWebtoonPreferences()
}
}
/**
* Init general reader preferences.
*/
private fun initGeneralPreferences() {
viewer.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
activity.presenter.setMangaViewer(position)
}
viewer.setSelection(activity.presenter.manga?.viewer ?: 0, false)
rotation_mode.bindToPreference(preferences.rotation(), 1)
background_color.bindToPreference(preferences.readerTheme())
show_page_number.bindToPreference(preferences.showPageNumber())
fullscreen.bindToPreference(preferences.fullscreen())
keepscreen.bindToPreference(preferences.keepScreenOn())
}
/**
* Init the preferences for the pager reader.
*/
private fun initPagerPreferences() {
pager_prefs_group.visible()
scale_type.bindToPreference(preferences.imageScaleType(), 1)
zoom_start.bindToPreference(preferences.zoomStart(), 1)
crop_borders.bindToPreference(preferences.cropBorders())
page_transitions.bindToPreference(preferences.pageTransitions())
}
/**
* Init the preferences for the webtoon reader.
*/
private fun initWebtoonPreferences() {
webtoon_prefs_group.visible()
crop_borders_webtoon.bindToPreference(preferences.cropBordersWebtoon())
}
/**
* Binds a checkbox or switch view with a boolean preference.
*/
private fun CompoundButton.bindToPreference(pref: Preference<Boolean>) {
isChecked = pref.getOrDefault()
setOnCheckedChangeListener { _, isChecked -> pref.set(isChecked) }
}
/**
* Binds a spinner to an int preference with an optional offset for the value.
*/
private fun Spinner.bindToPreference(pref: Preference<Int>, offset: Int = 0) {
onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
pref.set(position + offset)
}
setSelection(pref.getOrDefault() - offset, false)
}
}

View File

@ -16,6 +16,7 @@ import java.io.File
* Class used to show BigPictureStyle notifications * Class used to show BigPictureStyle notifications
*/ */
class SaveImageNotifier(private val context: Context) { class SaveImageNotifier(private val context: Context) {
/** /**
* Notification builder. * Notification builder.
*/ */

View File

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

View File

@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.ImageUtil
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import rx.Observable
import java.io.File
import java.io.FileInputStream
/**
* Loader used to load a chapter from a directory given on [file].
*/
class DirectoryPageLoader(val file: File) : PageLoader() {
/**
* Returns an observable containing the pages found on this directory ordered with a natural
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return file.listFiles()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.mapIndexed { i, file ->
val streamFn = { FileInputStream(file) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
.let { Observable.just(it) }
}
/**
* Returns an observable that emits a ready state.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(Page.READY)
}
}

View File

@ -0,0 +1,48 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import rx.Observable
import uy.kohesive.injekt.injectLazy
/**
* Loader used to load a chapter from the downloaded chapters.
*/
class DownloadPageLoader(
private val chapter: ReaderChapter,
private val manga: Manga,
private val source: Source,
private val downloadManager: DownloadManager
) : PageLoader() {
/**
* The application context. Needed to open input streams.
*/
private val context by injectLazy<Application>()
/**
* Returns an observable containing the pages found on this downloaded chapter.
*/
override fun getPages(): Observable<List<ReaderPage>> {
return downloadManager.buildPageList(source, manga, chapter.chapter)
.map { pages ->
pages.map { page ->
ReaderPage(page.index, page.url, page.imageUrl, {
context.contentResolver.openInputStream(page.uri)
}).apply {
status = Page.READY
}
}
}
}
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(Page.READY) // TODO maybe check if file still exists?
}
}

View File

@ -0,0 +1,54 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.EpubFile
import rx.Observable
import java.io.File
/**
* Loader used to load a chapter from a .epub file.
*/
class EpubPageLoader(file: File) : PageLoader() {
/**
* The epub file.
*/
private val epub = EpubFile(file)
/**
* Recycles this loader and the open zip.
*/
override fun recycle() {
super.recycle()
epub.close()
}
/**
* Returns an observable containing the pages found on this zip archive ordered with a natural
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
return epub.getImagesFromPages()
.mapIndexed { i, path ->
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
.let { Observable.just(it) }
}
/**
* Returns an observable that emits a ready state unless the loader was recycled.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(if (isRecycled) {
Page.ERROR
} else {
Page.READY
})
}
}

View File

@ -0,0 +1,222 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.plusAssign
import rx.Completable
import rx.Observable
import rx.schedulers.Schedulers
import rx.subjects.PublishSubject
import rx.subjects.SerializedSubject
import rx.subscriptions.CompositeSubscription
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.PriorityBlockingQueue
import java.util.concurrent.atomic.AtomicInteger
/**
* Loader used to load chapters from an online source.
*/
class HttpPageLoader(
private val chapter: ReaderChapter,
private val source: HttpSource,
private val chapterCache: ChapterCache = Injekt.get()
) : PageLoader() {
/**
* A queue used to manage requests one by one while allowing priorities.
*/
private val queue = PriorityBlockingQueue<PriorityPage>()
/**
* Current active subscriptions.
*/
private val subscriptions = CompositeSubscription()
init {
subscriptions += Observable.defer { Observable.just(queue.take().page) }
.filter { it.status == Page.QUEUE }
.concatMap { source.fetchImageFromCacheThenNet(it) }
.repeat()
.subscribeOn(Schedulers.io())
.subscribe({
}, { error ->
if (error !is InterruptedException) {
Timber.e(error)
}
})
}
/**
* Recycles this loader and the active subscriptions and queue.
*/
override fun recycle() {
super.recycle()
subscriptions.unsubscribe()
queue.clear()
// Cache current page list progress for online chapters to allow a faster reopen
val pages = chapter.pages
if (pages != null) {
// TODO check compatibility with ReaderPage
Completable.fromAction { chapterCache.putPageListToCache(chapter.chapter, pages) }
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
}
/**
* Returns an observable with the page list for a chapter. It tries to return the page list from
* the local cache, otherwise fallbacks to network.
*/
override fun getPages(): Observable<List<ReaderPage>> {
return chapterCache
.getPageListFromCache(chapter.chapter)
.onErrorResumeNext { source.fetchPageList(chapter.chapter) }
.map { pages ->
pages.mapIndexed { index, page -> // Don't trust sources and use our own indexing
ReaderPage(index, page.url, page.imageUrl)
}
}
}
/**
* Returns an observable that loads a page through the queue and listens to its result to
* emit new states. It handles re-enqueueing pages if they were evicted from the cache.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.defer {
val imageUrl = page.imageUrl
// Check if the image has been deleted
if (page.status == Page.READY && imageUrl != null && !chapterCache.isImageInCache(imageUrl)) {
page.status = Page.QUEUE
}
// Automatically retry failed pages when subscribed to this page
if (page.status == Page.ERROR) {
page.status = Page.QUEUE
}
val statusSubject = SerializedSubject(PublishSubject.create<Int>())
page.setStatusSubject(statusSubject)
if (page.status == Page.QUEUE) {
queue.offer(PriorityPage(page, 1))
}
preloadNextPages(page, 4)
statusSubject.startWith(page.status)
}
}
/**
* Preloads the given [amount] of pages after the [currentPage] with a lower priority.
*/
private fun preloadNextPages(currentPage: ReaderPage, amount: Int) {
val pageIndex = currentPage.index
val pages = currentPage.chapter.pages ?: return
if (pageIndex == pages.lastIndex) return
val nextPages = pages.subList(pageIndex + 1, Math.min(pageIndex + 1 + amount, pages.size))
for (nextPage in nextPages) {
if (nextPage.status == Page.QUEUE) {
queue.offer(PriorityPage(nextPage, 0))
}
}
}
/**
* Retries a page. This method is only called from user interaction on the viewer.
*/
override fun retryPage(page: ReaderPage) {
if (page.status == Page.ERROR) {
page.status = Page.QUEUE
}
queue.offer(PriorityPage(page, 2))
}
/**
* Data class used to keep ordering of pages in order to maintain priority.
*/
private data class PriorityPage(
val page: ReaderPage,
val priority: Int
): Comparable<PriorityPage> {
companion object {
private val idGenerator = AtomicInteger()
}
private val identifier = idGenerator.incrementAndGet()
override fun compareTo(other: PriorityPage): Int {
val p = other.priority.compareTo(priority)
return if (p != 0) p else identifier.compareTo(other.identifier)
}
}
/**
* Returns an observable of the page with the downloaded image.
*
* @param page the page whose source image has to be downloaded.
*/
private fun HttpSource.fetchImageFromCacheThenNet(page: ReaderPage): Observable<ReaderPage> {
return if (page.imageUrl.isNullOrEmpty())
getImageUrl(page).flatMap { getCachedImage(it) }
else
getCachedImage(page)
}
private fun HttpSource.getImageUrl(page: ReaderPage): Observable<ReaderPage> {
page.status = Page.LOAD_PAGE
return fetchImageUrl(page)
.doOnError { page.status = Page.ERROR }
.onErrorReturn { null }
.doOnNext { page.imageUrl = it }
.map { page }
}
/**
* Returns an observable of the page that gets the image from the chapter or fallbacks to
* network and copies it to the cache calling [cacheImage].
*
* @param page the page.
*/
private fun HttpSource.getCachedImage(page: ReaderPage): Observable<ReaderPage> {
val imageUrl = page.imageUrl ?: return Observable.just(page)
return Observable.just(page)
.flatMap {
if (!chapterCache.isImageInCache(imageUrl)) {
cacheImage(page)
} else {
Observable.just(page)
}
}
.doOnNext {
page.stream = { chapterCache.getImageFile(imageUrl).inputStream() }
page.status = Page.READY
}
.doOnError { page.status = Page.ERROR }
.onErrorReturn { page }
}
/**
* Returns an observable of the page that downloads the image to [ChapterCache].
*
* @param page the page.
*/
private fun HttpSource.cacheImage(page: ReaderPage): Observable<ReaderPage> {
page.status = Page.DOWNLOAD_IMAGE
return fetchImage(page)
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
.map { page }
}
}

View File

@ -0,0 +1,46 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.support.annotation.CallSuper
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import rx.Observable
/**
* A loader used to load pages into the reader. Any open resources must be cleaned up when the
* method [recycle] is called.
*/
abstract class PageLoader {
/**
* Whether this loader has been already recycled.
*/
var isRecycled = false
private set
/**
* Recycles this loader. Implementations must override this method to clean up any active
* resources.
*/
@CallSuper
open fun recycle() {
isRecycled = true
}
/**
* Returns an observable containing the list of pages of a chapter. Only the first emission
* will be used.
*/
abstract fun getPages(): Observable<List<ReaderPage>>
/**
* Returns an observable that should inform of the progress of the page (see the Page class
* for the available states)
*/
abstract fun getPage(page: ReaderPage): Observable<Int>
/**
* Retries the given [page] in case it failed to load. This method only makes sense when an
* online source is used.
*/
open fun retryPage(page: ReaderPage) {}
}

View File

@ -0,0 +1,89 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.ImageUtil
import junrar.Archive
import junrar.rarfile.FileHeader
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import rx.Observable
import java.io.File
import java.io.InputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.util.concurrent.Executors
/**
* Loader used to load a chapter from a .rar or .cbr file.
*/
class RarPageLoader(file: File) : PageLoader() {
/**
* The rar archive to load pages from.
*/
private val archive = Archive(file)
/**
* Pool for copying compressed files to an input stream.
*/
private val pool = Executors.newFixedThreadPool(1)
/**
* Recycles this loader and the open archive.
*/
override fun recycle() {
super.recycle()
archive.close()
pool.shutdown()
}
/**
* Returns an observable containing the pages found on this rar archive ordered with a natural
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return archive.fileHeaders
.filter { !it.isDirectory && ImageUtil.isImage(it.fileNameString) { archive.getInputStream(it) } }
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
.mapIndexed { i, header ->
val streamFn = { getStream(header) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
.let { Observable.just(it) }
}
/**
* Returns an observable that emits a ready state unless the loader was recycled.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(if (isRecycled) {
Page.ERROR
} else {
Page.READY
})
}
/**
* Returns an input stream for the given [header].
*/
private fun getStream(header: FileHeader): InputStream {
val pipeIn = PipedInputStream()
val pipeOut = PipedOutputStream(pipeIn)
pool.execute {
try {
pipeOut.use {
archive.extractFile(header, it)
}
} catch (e: Exception) {
}
}
return pipeIn
}
}

View File

@ -0,0 +1,60 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.ImageUtil
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import rx.Observable
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Loader used to load a chapter from a .zip or .cbz file.
*/
class ZipPageLoader(file: File) : PageLoader() {
/**
* The zip file to load pages from.
*/
private val zip = ZipFile(file)
/**
* Recycles this loader and the open zip.
*/
override fun recycle() {
super.recycle()
zip.close()
}
/**
* Returns an observable containing the pages found on this zip archive ordered with a natural
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return zip.entries().toList()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.mapIndexed { i, entry ->
val streamFn = { zip.getInputStream(entry) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
.let { Observable.just(it) }
}
/**
* Returns an observable that emits a ready state unless the loader was recycled.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(if (isRecycled) {
Page.ERROR
} else {
Page.READY
})
}
}

View File

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

View File

@ -0,0 +1,58 @@
package eu.kanade.tachiyomi.ui.reader.model
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import timber.log.Timber
data class ReaderChapter(val chapter: Chapter) {
var state: State =
State.Wait
set(value) {
field = value
stateRelay.call(value)
}
private val stateRelay by lazy { BehaviorRelay.create(state) }
val stateObserver by lazy { stateRelay.asObservable() }
val pages: List<ReaderPage>?
get() = (state as? State.Loaded)?.pages
var pageLoader: PageLoader? = null
var requestedPage: Int = 0
val isDownloaded
get() = pageLoader is DownloadPageLoader
var references = 0
private set
fun ref() {
references++
}
fun unref() {
references--
if (references == 0) {
if (pageLoader != null) {
Timber.d("Recycling chapter ${chapter.name}")
}
pageLoader?.recycle()
pageLoader = null
state = State.Wait
}
}
sealed class State {
object Wait : State()
object Loading : State()
class Error(val error: Throwable) : State()
class Loaded(val pages: List<ReaderPage>) : State()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,253 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.base
import android.support.v4.app.Fragment
import com.davemorrissey.labs.subscaleview.decoder.*
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
import java.util.*
/**
* Base reader containing the common data that can be used by its implementations. It does not
* contain any UI related action.
*/
abstract class BaseReader : Fragment() {
companion object {
/**
* Image decoder.
*/
const val IMAGE_DECODER = 0
/**
* Rapid decoder.
*/
const val RAPID_DECODER = 1
/**
* Skia decoder.
*/
const val SKIA_DECODER = 2
}
/**
* List of chapters added in the reader.
*/
private val chapters = ArrayList<ReaderChapter>()
/**
* List of pages added in the reader. It can contain pages from more than one chapter.
*/
var pages: MutableList<Page> = ArrayList()
private set
/**
* Current visible position of [pages].
*/
var currentPage: Int = 0
protected set
/**
* Region decoder class to use.
*/
lateinit var regionDecoderClass: Class<out ImageRegionDecoder>
private set
/**
* Bitmap decoder class to use.
*/
lateinit var bitmapDecoderClass: Class<out ImageDecoder>
private set
/**
* Whether tap navigation is enabled or not.
*/
val tappingEnabled by lazy { readerActivity.preferences.readWithTapping().getOrDefault() }
/**
* Whether the reader has requested to append a chapter. Used with seamless mode to avoid
* restarting requests when changing pages.
*/
private var hasRequestedNextChapter: Boolean = false
/**
* Returns the active page.
*/
fun getActivePage(): Page? {
return pages.getOrNull(currentPage)
}
/**
* Called when a page changes. Implementations must call this method.
*
* @param position the new current page.
*/
fun onPageChanged(position: Int) {
val oldPage = pages[currentPage]
val newPage = pages[position]
val oldChapter = oldPage.chapter
val newChapter = newPage.chapter
// Update page indicator and seekbar
readerActivity.onPageChanged(newPage)
// Active chapter has changed.
if (oldChapter.id != newChapter.id) {
readerActivity.onEnterChapter(newPage.chapter, newPage.index)
}
// Request next chapter only when the conditions are met.
if (pages.size - position < 5 && chapters.last().id == newChapter.id
&& readerActivity.presenter.hasNextChapter() && !hasRequestedNextChapter) {
hasRequestedNextChapter = true
readerActivity.presenter.appendNextChapter()
}
currentPage = position
}
/**
* Sets the active page.
*
* @param page the page to display.
*/
fun setActivePage(page: Page) {
setActivePage(getPageIndex(page))
}
/**
* Searchs for the index of a page in the current list without requiring them to be the same
* object.
*
* @param search the page to search.
* @return the index of the page in [pages] or 0 if it's not found.
*/
fun getPageIndex(search: Page): Int {
for ((index, page) in pages.withIndex()) {
if (page.index == search.index && page.chapter.id == search.chapter.id) {
return index
}
}
return 0
}
/**
* Called from the presenter when the page list of a chapter is ready. This method is called
* on every [onResume], so we add some logic to avoid duplicating chapters.
*
* @param chapter the chapter to set.
* @param currentPage the initial page to display.
*/
fun onPageListReady(chapter: ReaderChapter, currentPage: Page) {
if (!chapters.contains(chapter)) {
// if we reset the loaded page we also need to reset the loaded chapters
chapters.clear()
chapters.add(chapter)
pages = ArrayList(chapter.pages)
onChapterSet(chapter, currentPage)
} else {
setActivePage(currentPage)
}
}
/**
* Called from the presenter when the page list of a chapter to append is ready. This method is
* called on every [onResume], so we add some logic to avoid duplicating chapters.
*
* @param chapter the chapter to append.
*/
fun onPageListAppendReady(chapter: ReaderChapter) {
if (!chapters.contains(chapter)) {
hasRequestedNextChapter = false
chapters.add(chapter)
pages.addAll(chapter.pages!!)
onChapterAppended(chapter)
}
}
/**
* Sets the active page.
*
* @param pageNumber the index of the page from [pages].
*/
abstract fun setActivePage(pageNumber: Int)
/**
* Called when a new chapter is set in [BaseReader].
*
* @param chapter the chapter set.
* @param currentPage the initial page to display.
*/
abstract fun onChapterSet(chapter: ReaderChapter, currentPage: Page)
/**
* Called when a chapter is appended in [BaseReader].
*
* @param chapter the chapter appended.
*/
abstract fun onChapterAppended(chapter: ReaderChapter)
/**
* Moves pages to right. Implementations decide how to move (by a page, by some distance...).
*/
abstract fun moveRight()
/**
* Moves pages to left. Implementations decide how to move (by a page, by some distance...).
*/
abstract fun moveLeft()
/**
* Moves pages down. Implementations decide how to move (by a page, by some distance...).
*/
open fun moveDown() {
moveRight()
}
/**
* Moves pages up. Implementations decide how to move (by a page, by some distance...).
*/
open fun moveUp() {
moveLeft()
}
/**
* Method the implementations can call to show a menu with options for the given page.
*/
fun onLongClick(page: Page?): Boolean {
if (isAdded && page != null) {
readerActivity.onLongClick(page)
}
return true
}
/**
* Sets the active decoder class.
*
* @param value the decoder class to use.
*/
fun setDecoderClass(value: Int) {
when (value) {
IMAGE_DECODER -> {
bitmapDecoderClass = IImageDecoder::class.java
regionDecoderClass = IImageRegionDecoder::class.java
}
RAPID_DECODER -> {
bitmapDecoderClass = RapidImageDecoder::class.java
regionDecoderClass = RapidImageRegionDecoder::class.java
}
SKIA_DECODER -> {
bitmapDecoderClass = SkiaImageDecoder::class.java
regionDecoderClass = SkiaImageRegionDecoder::class.java
}
}
}
/**
* Property to get the reader activity.
*/
val readerActivity: ReaderActivity
get() = activity as ReaderActivity
}

View File

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

View File

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

View File

@ -1,276 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.content.Context
import android.graphics.PointF
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.viewer.base.PageDecodeErrorLayout
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.RightToLeftReader
import eu.kanade.tachiyomi.ui.reader.viewer.pager.vertical.VerticalReader
import eu.kanade.tachiyomi.util.inflate
import kotlinx.android.synthetic.main.reader_pager_item.view.*
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.subjects.PublishSubject
import rx.subjects.SerializedSubject
import java.util.concurrent.TimeUnit
class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
: FrameLayout(context, attrs) {
/**
* Page of a chapter.
*/
lateinit var page: Page
/**
* Subscription for status changes of the page.
*/
private var statusSubscription: Subscription? = null
/**
* Subscription for progress changes of the page.
*/
private var progressSubscription: Subscription? = null
/**
* Layout of decode error.
*/
private var decodeErrorLayout: View? = null
fun initialize(reader: PagerReader, page: Page) {
val activity = reader.activity as ReaderActivity
when (activity.readerTheme) {
ReaderActivity.BLACK_THEME -> progress_text.setTextColor(reader.whiteColor)
ReaderActivity.WHITE_THEME -> progress_text.setTextColor(reader.blackColor)
}
if (reader is RightToLeftReader) {
rotation = -180f
}
with(image_view) {
setMaxTileSize((reader.activity as ReaderActivity).maxBitmapSize)
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_FIXED)
setDoubleTapZoomDuration(reader.doubleTapAnimDuration.toInt())
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
setMinimumScaleType(reader.scaleType)
setMinimumDpi(90)
setMinimumTileDpi(180)
setRegionDecoderClass(reader.regionDecoderClass)
setBitmapDecoderClass(reader.bitmapDecoderClass)
setVerticalScrollingParent(reader is VerticalReader)
setCropBorders(reader.cropBorders)
setOnTouchListener { _, motionEvent -> reader.gestureDetector.onTouchEvent(motionEvent) }
setOnLongClickListener { reader.onLongClick(page) }
setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
override fun onReady() {
onImageDecoded(reader)
}
override fun onImageLoadError(e: Exception) {
onImageDecodeError(reader)
}
})
}
retry_button.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_UP) {
activity.presenter.retryPage(page)
}
true
}
this.page = page
observeStatus()
}
override fun onDetachedFromWindow() {
unsubscribeProgress()
unsubscribeStatus()
image_view.setOnTouchListener(null)
image_view.setOnImageEventListener(null)
super.onDetachedFromWindow()
}
/**
* Observes the status of the page and notify the changes.
*
* @see processStatus
*/
private fun observeStatus() {
statusSubscription?.unsubscribe()
val statusSubject = SerializedSubject(PublishSubject.create<Int>())
page.setStatusSubject(statusSubject)
statusSubscription = statusSubject.startWith(page.status)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { processStatus(it) }
}
/**
* Observes the progress of the page and updates view.
*/
private fun observeProgress() {
progressSubscription?.unsubscribe()
progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
.map { page.progress }
.distinctUntilChanged()
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { progress ->
progress_text.text = if (progress > 0) {
context.getString(R.string.download_progress, progress)
} else {
context.getString(R.string.downloading)
}
}
}
/**
* Called when the status of the page changes.
*
* @param status the new status of the page.
*/
private fun processStatus(status: Int) {
when (status) {
Page.QUEUE -> setQueued()
Page.LOAD_PAGE -> setLoading()
Page.DOWNLOAD_IMAGE -> {
observeProgress()
setDownloading()
}
Page.READY -> {
setImage()
unsubscribeProgress()
}
Page.ERROR -> {
setError()
unsubscribeProgress()
}
}
}
/**
* Unsubscribes from the status subscription.
*/
private fun unsubscribeStatus() {
page.setStatusSubject(null)
statusSubscription?.unsubscribe()
statusSubscription = null
}
/**
* Unsubscribes from the progress subscription.
*/
private fun unsubscribeProgress() {
progressSubscription?.unsubscribe()
progressSubscription = null
}
/**
* Called when the page is queued.
*/
private fun setQueued() {
progress_container.visibility = View.VISIBLE
progress_text.visibility = View.INVISIBLE
retry_button.visibility = View.GONE
decodeErrorLayout?.let {
removeView(it)
decodeErrorLayout = null
}
}
/**
* Called when the page is loading.
*/
private fun setLoading() {
progress_container.visibility = View.VISIBLE
progress_text.visibility = View.VISIBLE
progress_text.setText(R.string.downloading)
}
/**
* Called when the page is downloading.
*/
private fun setDownloading() {
progress_container.visibility = View.VISIBLE
progress_text.visibility = View.VISIBLE
}
/**
* Called when the page is ready.
*/
private fun setImage() {
val uri = page.uri
if (uri == null) {
page.status = Page.ERROR
return
}
val file = UniFile.fromUri(context, uri)
if (!file.exists()) {
page.status = Page.ERROR
return
}
progress_text.visibility = View.INVISIBLE
image_view.setImage(ImageSource.uri(file.uri))
}
/**
* Called when the page has an error.
*/
private fun setError() {
progress_container.visibility = View.GONE
retry_button.visibility = View.VISIBLE
}
/**
* Called when the image is decoded and going to be displayed.
*/
private fun onImageDecoded(reader: PagerReader) {
progress_container.visibility = View.GONE
with(image_view) {
when (reader.zoomType) {
PagerReader.ALIGN_LEFT -> setScaleAndCenter(scale, PointF(0f, 0f))
PagerReader.ALIGN_RIGHT -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0f))
PagerReader.ALIGN_CENTER -> setScaleAndCenter(scale, center.apply { y = 0f })
}
}
}
/**
* Called when an image fails to decode.
*/
private fun onImageDecodeError(reader: PagerReader) {
progress_container.visibility = View.GONE
if (decodeErrorLayout != null || !reader.isAdded) return
val activity = reader.activity as ReaderActivity
val layout = inflate(R.layout.reader_page_decode_error)
PageDecodeErrorLayout(layout, page, activity.readerTheme, {
if (reader.isAdded) {
activity.presenter.retryPage(page)
}
})
decodeErrorLayout = layout
addView(layout)
}
}

View File

@ -1,28 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager;
import android.support.v4.view.PagerAdapter;
import android.view.ViewGroup;
import rx.functions.Action1;
public interface Pager {
void setId(int id);
void setLayoutParams(ViewGroup.LayoutParams layoutParams);
void setOffscreenPageLimit(int limit);
int getCurrentItem();
void setCurrentItem(int item, boolean smoothScroll);
int getWidth();
int getHeight();
PagerAdapter getAdapter();
void setAdapter(PagerAdapter adapter);
void setOnChapterBoundariesOutListener(OnChapterBoundariesOutListener listener);
void setOnPageChangeListener(Action1<Integer> onPageChanged);
void clearOnPageChangeListeners();
}

View File

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

View File

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

View File

@ -0,0 +1,107 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import com.f2prateek.rx.preferences.Preference
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.addTo
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Configuration used by pager viewers.
*/
class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelper = Injekt.get()) {
private val subscriptions = CompositeSubscription()
var imagePropertyChangedListener: (() -> Unit)? = null
var tappingEnabled = true
private set
var volumeKeysEnabled = false
private set
var volumeKeysInverted = false
private set
var usePageTransitions = false
private set
var imageScaleType = 1
private set
var imageZoomType = ZoomType.Left
private set
var imageCropBorders = false
private set
var doubleTapAnimDuration = 500
private set
init {
preferences.readWithTapping()
.register({ tappingEnabled = it })
preferences.pageTransitions()
.register({ usePageTransitions = it })
preferences.imageScaleType()
.register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() })
preferences.zoomStart()
.register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() })
preferences.cropBorders()
.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
preferences.doubleTapAnimSpeed()
.register({ doubleTapAnimDuration = it })
preferences.readWithVolumeKeys()
.register({ volumeKeysEnabled = it })
preferences.readWithVolumeKeysInverted()
.register({ volumeKeysInverted = it })
}
fun unsubscribe() {
subscriptions.unsubscribe()
}
private fun <T> Preference<T>.register(
valueAssignment: (T) -> Unit,
onChanged: (T) -> Unit = {}
) {
asObservable()
.doOnNext(valueAssignment)
.skip(1)
.distinctUntilChanged()
.doOnNext(onChanged)
.subscribe()
.addTo(subscriptions)
}
private fun zoomTypeFromPreference(value: Int) {
imageZoomType = when (value) {
// Auto
1 -> when (viewer) {
is L2RPagerViewer -> ZoomType.Left
is R2LPagerViewer -> ZoomType.Right
else -> ZoomType.Center
}
// Left
2 -> ZoomType.Left
// Right
3 -> ZoomType.Right
// Center
else -> ZoomType.Center
}
}
enum class ZoomType {
Left, Center, Right
}
}

View File

@ -0,0 +1,464 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.PointF
import android.graphics.drawable.Drawable
import android.net.Uri
import android.view.GestureDetector
import android.view.Gravity
import android.view.MotionEvent
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.github.chrisbanes.photoview.PhotoView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressBar
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerConfig.ZoomType
import eu.kanade.tachiyomi.util.ImageUtil
import eu.kanade.tachiyomi.util.dpToPx
import eu.kanade.tachiyomi.util.gone
import eu.kanade.tachiyomi.util.visible
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import java.io.InputStream
import java.util.concurrent.TimeUnit
/**
* View of the ViewPager that contains a page of a chapter.
*/
@SuppressLint("ViewConstructor")
class PagerPageHolder(
val viewer: PagerViewer,
val page: ReaderPage
) : FrameLayout(viewer.activity), ViewPagerAdapter.PositionableView {
/**
* Item that identifies this view. Needed by the adapter to not recreate views.
*/
override val item
get() = page
/**
* Loading progress bar to indicate the current progress.
*/
private val progressBar = createProgressBar()
/**
* Image view that supports subsampling on zoom.
*/
private var subsamplingImageView: SubsamplingScaleImageView? = null
/**
* Simple image view only used on GIFs.
*/
private var imageView: ImageView? = null
/**
* Retry button used to allow retrying.
*/
private var retryButton: PagerButton? = null
/**
* Error layout to show when the image fails to decode.
*/
private var decodeErrorLayout: ViewGroup? = null
/**
* Subscription for status changes of the page.
*/
private var statusSubscription: Subscription? = null
/**
* Subscription for progress changes of the page.
*/
private var progressSubscription: Subscription? = null
/**
* Subscription used to read the header of the image. This is needed in order to instantiate
* the appropiate image view depending if the image is animated (GIF).
*/
private var readImageHeaderSubscription: Subscription? = null
init {
addView(progressBar)
observeStatus()
}
/**
* Called when this view is detached from the window. Unsubscribes any active subscription.
*/
@SuppressLint("ClickableViewAccessibility")
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
unsubscribeProgress()
unsubscribeStatus()
unsubscribeReadImageHeader()
subsamplingImageView?.setOnImageEventListener(null)
}
/**
* Observes the status of the page and notify the changes.
*
* @see processStatus
*/
private fun observeStatus() {
statusSubscription?.unsubscribe()
val loader = page.chapter.pageLoader ?: return
statusSubscription = loader.getPage(page)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { processStatus(it) }
}
/**
* Observes the progress of the page and updates view.
*/
private fun observeProgress() {
progressSubscription?.unsubscribe()
progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
.map { page.progress }
.distinctUntilChanged()
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { value -> progressBar.setProgress(value) }
}
/**
* Called when the status of the page changes.
*
* @param status the new status of the page.
*/
private fun processStatus(status: Int) {
when (status) {
Page.QUEUE -> setQueued()
Page.LOAD_PAGE -> setLoading()
Page.DOWNLOAD_IMAGE -> {
observeProgress()
setDownloading()
}
Page.READY -> {
setImage()
unsubscribeProgress()
}
Page.ERROR -> {
setError()
unsubscribeProgress()
}
}
}
/**
* Unsubscribes from the status subscription.
*/
private fun unsubscribeStatus() {
statusSubscription?.unsubscribe()
statusSubscription = null
}
/**
* Unsubscribes from the progress subscription.
*/
private fun unsubscribeProgress() {
progressSubscription?.unsubscribe()
progressSubscription = null
}
/**
* Unsubscribes from the read image header subscription.
*/
private fun unsubscribeReadImageHeader() {
readImageHeaderSubscription?.unsubscribe()
readImageHeaderSubscription = null
}
/**
* Called when the page is queued.
*/
private fun setQueued() {
progressBar.visible()
retryButton?.gone()
decodeErrorLayout?.gone()
}
/**
* Called when the page is loading.
*/
private fun setLoading() {
progressBar.visible()
retryButton?.gone()
decodeErrorLayout?.gone()
}
/**
* Called when the page is downloading.
*/
private fun setDownloading() {
progressBar.visible()
retryButton?.gone()
decodeErrorLayout?.gone()
}
/**
* Called when the page is ready.
*/
private fun setImage() {
progressBar.visible()
progressBar.completeAndFadeOut()
retryButton?.gone()
decodeErrorLayout?.gone()
unsubscribeReadImageHeader()
val streamFn = page.stream ?: return
var openStream: InputStream? = null
readImageHeaderSubscription = Observable
.fromCallable {
val stream = streamFn().buffered(16)
openStream = stream
ImageUtil.findImageType(stream) == ImageUtil.ImageType.GIF
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { isAnimated ->
if (!isAnimated) {
initSubsamplingImageView().setImage(ImageSource.inputStream(openStream!!))
} else {
initImageView().setImage(openStream!!)
}
}
// Keep the Rx stream alive to close the input stream only when unsubscribed
.flatMap { Observable.never<Unit>() }
.doOnUnsubscribe { openStream?.close() }
.subscribe({}, {})
}
/**
* Called when the page has an error.
*/
private fun setError() {
progressBar.gone()
initRetryButton().visible()
}
/**
* Called when the image is decoded and going to be displayed.
*/
private fun onImageDecoded() {
progressBar.gone()
}
/**
* Called when an image fails to decode.
*/
private fun onImageDecodeError() {
progressBar.gone()
initDecodeErrorLayout().visible()
}
/**
* Creates a new progress bar.
*/
@SuppressLint("PrivateResource")
private fun createProgressBar(): ReaderProgressBar {
return ReaderProgressBar(context, null).apply {
val size = 48.dpToPx
layoutParams = FrameLayout.LayoutParams(size, size).apply {
gravity = Gravity.CENTER
}
}
}
/**
* Initializes a subsampling scale view.
*/
private fun initSubsamplingImageView(): SubsamplingScaleImageView {
if (subsamplingImageView != null) return subsamplingImageView!!
val config = viewer.config
subsamplingImageView = SubsamplingScaleImageView(context).apply {
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
setMaxTileSize(viewer.activity.maxBitmapSize)
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_FIXED)
setDoubleTapZoomDuration(config.doubleTapAnimDuration)
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
setMinimumScaleType(config.imageScaleType)
setMinimumDpi(90)
setMinimumTileDpi(180)
setCropBorders(config.imageCropBorders)
setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
override fun onReady() {
when (config.imageZoomType) {
ZoomType.Left -> setScaleAndCenter(scale, PointF(0f, 0f))
ZoomType.Right -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0f))
ZoomType.Center -> setScaleAndCenter(scale, center.apply { y = 0f })
}
onImageDecoded()
}
override fun onImageLoadError(e: Exception) {
onImageDecodeError()
}
})
}
addView(subsamplingImageView)
return subsamplingImageView!!
}
/**
* Initializes an image view, used for GIFs.
*/
private fun initImageView(): ImageView {
if (imageView != null) return imageView!!
imageView = PhotoView(context, null).apply {
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
adjustViewBounds = true
setZoomTransitionDuration(viewer.config.doubleTapAnimDuration)
setScaleLevels(1f, 2f, 3f)
// Force 2 scale levels on double tap
setOnDoubleTapListener(object : GestureDetector.SimpleOnGestureListener() {
override fun onDoubleTap(e: MotionEvent): Boolean {
if (scale > 1f) {
setScale(1f, e.x, e.y, true)
} else {
setScale(2f, e.x, e.y, true)
}
return true
}
})
}
addView(imageView)
return imageView!!
}
/**
* Initializes a button to retry pages.
*/
private fun initRetryButton(): PagerButton {
if (retryButton != null) return retryButton!!
retryButton = PagerButton(context, viewer).apply {
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
gravity = Gravity.CENTER
}
setText(R.string.action_retry)
setOnClickListener {
page.chapter.pageLoader?.retryPage(page)
}
}
addView(retryButton)
return retryButton!!
}
/**
* Initializes a decode error layout.
*/
private fun initDecodeErrorLayout(): ViewGroup {
if (decodeErrorLayout != null) return decodeErrorLayout!!
val margins = 8.dpToPx
val decodeLayout = LinearLayout(context).apply {
gravity = Gravity.CENTER
orientation = LinearLayout.VERTICAL
}
decodeErrorLayout = decodeLayout
TextView(context).apply {
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
setMargins(margins, margins, margins, margins)
}
gravity = Gravity.CENTER
setText(R.string.decode_image_error)
decodeLayout.addView(this)
}
PagerButton(context, viewer).apply {
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
setMargins(margins, margins, margins, margins)
}
setText(R.string.action_retry)
setOnClickListener {
page.chapter.pageLoader?.retryPage(page)
}
decodeLayout.addView(this)
}
val imageUrl = page.imageUrl
if (imageUrl.orEmpty().startsWith("http")) {
PagerButton(context, viewer).apply {
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
setMargins(margins, margins, margins, margins)
}
setText(R.string.action_open_in_browser)
setOnClickListener {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(imageUrl))
context.startActivity(intent)
}
decodeLayout.addView(this)
}
}
addView(decodeLayout)
return decodeLayout
}
/**
* Extension method to set a [stream] into this ImageView.
*/
private fun ImageView.setImage(stream: InputStream) {
GlideApp.with(this)
.load(stream)
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
onImageDecodeError()
return false
}
override fun onResourceReady(
resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean
): Boolean {
onImageDecoded()
return false
}
})
.into(this)
}
}

View File

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

View File

@ -1,47 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.support.v4.view.PagerAdapter
import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
/**
* Adapter of pages for a ViewPager.
*/
class PagerReaderAdapter(private val reader: PagerReader) : ViewPagerAdapter() {
/**
* Pages stored in the adapter.
*/
var pages: List<Page> = emptyList()
set(value) {
field = value
notifyDataSetChanged()
}
override fun createView(container: ViewGroup, position: Int): View {
val view = container.inflate(R.layout.reader_pager_item) as PageView
view.initialize(reader, pages[position])
return view
}
/**
* Returns the number of pages.
*/
override fun getCount(): Int {
return pages.size
}
override fun getItemPosition(obj: Any): Int {
val view = obj as PageView
return if (view.page in pages) {
PagerAdapter.POSITION_UNCHANGED
} else {
PagerAdapter.POSITION_NONE
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,101 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.support.v4.view.PagerAdapter
import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import timber.log.Timber
/**
* Pager adapter used by this [viewer] to where [ViewerChapters] updates are posted.
*/
class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
/**
* List of currently set items.
*/
var items: List<Any> = emptyList()
private set
/**
* Updates this adapter with the given [chapters]. It handles setting a few pages of the
* next/previous chapter to allow seamless transitions and inverting the pages if the viewer
* has R2L direction.
*/
fun setChapters(chapters: ViewerChapters) {
val newItems = mutableListOf<Any>()
// Add previous chapter pages and transition.
if (chapters.prevChapter != null) {
// We only need to add the last few pages of the previous chapter, because it'll be
// selected as the current chapter when one of those pages is selected.
val prevPages = chapters.prevChapter.pages
if (prevPages != null) {
newItems.addAll(prevPages.takeLast(2))
}
}
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
// Add current chapter.
val currPages = chapters.currChapter.pages
if (currPages != null) {
newItems.addAll(currPages)
}
// Add next chapter transition and pages.
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
if (chapters.nextChapter != null) {
// Add at most two pages, because this chapter will be selected before the user can
// swap more pages.
val nextPages = chapters.nextChapter.pages
if (nextPages != null) {
newItems.addAll(nextPages.take(2))
}
}
if (viewer is R2LPagerViewer) {
newItems.reverse()
}
items = newItems
notifyDataSetChanged()
}
/**
* Returns the amount of items of the adapter.
*/
override fun getCount(): Int {
return items.size
}
/**
* Creates a new view for the item at the given [position].
*/
override fun createView(container: ViewGroup, position: Int): View {
val item = items[position]
return when (item) {
is ReaderPage -> PagerPageHolder(viewer, item)
is ChapterTransition -> PagerTransitionHolder(viewer, item)
else -> throw NotImplementedError("Holder for ${item.javaClass} not implemented")
}
}
/**
* Returns the current position of the given [view] on the adapter.
*/
override fun getItemPosition(view: Any): Int {
if (view is PositionableView) {
val position = items.indexOf(view.item)
if (position != -1) {
return position
} else {
Timber.d("Position for ${view.item} not found")
}
}
return PagerAdapter.POSITION_NONE
}
}

View File

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

View File

@ -1,86 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal
import android.content.Context
import android.support.v4.view.ViewPager
import android.view.MotionEvent
import eu.kanade.tachiyomi.ui.reader.viewer.pager.OnChapterBoundariesOutListener
import eu.kanade.tachiyomi.ui.reader.viewer.pager.Pager
import rx.functions.Action1
/**
* Implementation of a [ViewPager] to add custom behavior on touch events.
*/
class HorizontalPager(context: Context) : ViewPager(context), Pager {
companion object {
const val SWIPE_TOLERANCE = 0.25f
}
private var onChapterBoundariesOutListener: OnChapterBoundariesOutListener? = null
private var startDragX: Float = 0f
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
try {
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_DOWN) {
if (currentItem == 0 || currentItem == adapter!!.count - 1) {
startDragX = ev.x
}
}
return super.onInterceptTouchEvent(ev)
} catch (e: IllegalArgumentException) {
return false
}
}
override fun onTouchEvent(ev: MotionEvent): Boolean {
try {
onChapterBoundariesOutListener?.let { listener ->
if (currentItem == 0) {
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
val displacement = ev.x - startDragX
if (ev.x > startDragX && displacement > width * SWIPE_TOLERANCE) {
listener.onFirstPageOutEvent()
return true
}
startDragX = 0f
}
} else if (currentItem == adapter!!.count - 1) {
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
val displacement = startDragX - ev.x
if (ev.x < startDragX && displacement > width * SWIPE_TOLERANCE) {
listener.onLastPageOutEvent()
return true
}
startDragX = 0f
}
}
}
return super.onTouchEvent(ev)
} catch (e: IllegalArgumentException) {
return false
}
}
override fun setOnChapterBoundariesOutListener(listener: OnChapterBoundariesOutListener) {
onChapterBoundariesOutListener = listener
}
override fun setOnPageChangeListener(func: Action1<Int>) {
addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
func.call(position)
}
})
}
}

View File

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

View File

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

View File

@ -1,84 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager.vertical
import android.content.Context
import android.view.MotionEvent
import eu.kanade.tachiyomi.ui.reader.viewer.pager.OnChapterBoundariesOutListener
import eu.kanade.tachiyomi.ui.reader.viewer.pager.Pager
import rx.functions.Action1
/**
* Implementation of a [VerticalViewPagerImpl] to add custom behavior on touch events.
*/
class VerticalPager(context: Context) : VerticalViewPagerImpl(context), Pager {
private var onChapterBoundariesOutListener: OnChapterBoundariesOutListener? = null
private var startDragY: Float = 0.toFloat()
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
try {
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_DOWN) {
if (currentItem == 0 || currentItem == adapter.count - 1) {
startDragY = ev.y
}
}
return super.onInterceptTouchEvent(ev)
} catch (e: IllegalArgumentException) {
return false
}
}
override fun onTouchEvent(ev: MotionEvent): Boolean {
try {
onChapterBoundariesOutListener?.let { listener ->
if (currentItem == 0) {
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
val displacement = ev.y - startDragY
if (ev.y > startDragY && displacement > height * SWIPE_TOLERANCE) {
listener.onFirstPageOutEvent()
return true
}
startDragY = 0f
}
} else if (currentItem == adapter.count - 1) {
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
val displacement = startDragY - ev.y
if (ev.y < startDragY && displacement > height * SWIPE_TOLERANCE) {
listener.onLastPageOutEvent()
return true
}
startDragY = 0f
}
}
}
return super.onTouchEvent(ev)
} catch (e: IllegalArgumentException) {
return false
}
}
override fun setOnChapterBoundariesOutListener(listener: OnChapterBoundariesOutListener) {
onChapterBoundariesOutListener = listener
}
override fun setOnPageChangeListener(func: Action1<Int>) {
addOnPageChangeListener(object : VerticalViewPagerImpl.SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
func.call(position)
}
})
}
companion object {
private val SWIPE_TOLERANCE = 0.25f
}
}

View File

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

View File

@ -1,78 +1,172 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.support.v7.util.DiffUtil
import android.support.v7.widget.RecyclerView import android.support.v7.widget.RecyclerView
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import eu.kanade.tachiyomi.R import android.widget.FrameLayout
import eu.kanade.tachiyomi.source.model.Page import android.widget.LinearLayout
import eu.kanade.tachiyomi.util.inflate 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. * RecyclerView Adapter used by this [viewer] to where [ViewerChapters] updates are posted.
*
* @param fragment the fragment containing this adapter.
*/ */
class WebtoonAdapter(val fragment: WebtoonReader) : RecyclerView.Adapter<WebtoonHolder>() { class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
/** /**
* Pages stored in the adapter. * List of currently set items.
*/ */
var pages: List<Page>? = null var items: List<Any> = emptyList()
private set
/** /**
* Touch listener for images in holders. * Updates this adapter with the given [chapters]. It handles setting a few pages of the
* next/previous chapter to allow seamless transitions.
*/ */
val touchListener = View.OnTouchListener { _, ev -> fragment.imageGestureDetector.onTouchEvent(ev) } fun setChapters(chapters: ViewerChapters) {
val newItems = mutableListOf<Any>()
// Add previous chapter pages and transition.
if (chapters.prevChapter != null) {
// We only need to add the last few pages of the previous chapter, because it'll be
// selected as the current chapter when one of those pages is selected.
val prevPages = chapters.prevChapter.pages
if (prevPages != null) {
newItems.addAll(prevPages.takeLast(2))
}
}
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
// Add current chapter.
val currPages = chapters.currChapter.pages
if (currPages != null) {
newItems.addAll(currPages)
}
// Add next chapter transition and pages.
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
if (chapters.nextChapter != null) {
// Add at most two pages, because this chapter will be selected before the user can
// swap more pages.
val nextPages = chapters.nextChapter.pages
if (nextPages != null) {
newItems.addAll(nextPages.take(2))
}
}
val result = DiffUtil.calculateDiff(Callback(items, newItems))
items = newItems
result.dispatchUpdatesTo(this)
}
/** /**
* Returns the number of pages. * Returns the amount of items of the adapter.
*
* @return the number of pages or 0 if the list is null.
*/ */
override fun getItemCount(): Int { override fun getItemCount(): Int {
return pages?.size ?: 0 return items.size
} }
/** /**
* Returns a page given the position. * Returns the view type for the item at the given [position].
*
* @param position the position of the page.
* @return the page.
*/ */
fun getItem(position: Int): Page { override fun getItemViewType(position: Int): Int {
return pages!![position] 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. * Creates a new view holder for an item with the given [viewType].
*
* @param parent the parent view.
* @param viewType the type of the holder.
* @return a new view holder for a manga.
*/ */
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WebtoonHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val v = parent.inflate(R.layout.reader_webtoon_item) return when (viewType) {
return WebtoonHolder(v, this) 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. * Binds an existing view [holder] with the item at the given [position].
*
* @param holder the holder to bind.
* @param position the position to bind.
*/ */
override fun onBindViewHolder(holder: WebtoonHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val page = getItem(position) val item = items[position]
holder.onSetValues(page) when (holder) {
is WebtoonPageHolder -> holder.bind(item as ReaderPage)
is WebtoonTransitionHolder -> holder.bind(item as ChapterTransition)
}
} }
/** /**
* Recycles the view holder. * Recycles an existing view [holder] before adding it to the view pool.
*
* @param holder the holder to recycle.
*/ */
override fun onViewRecycled(holder: WebtoonHolder) { override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
holder.onRecycle() 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<Any>,
private val newItems: List<Any>
) : DiffUtil.Callback() {
/**
* Returns true if these two items are the same.
*/
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldItems[oldItemPosition]
val newItem = newItems[newItemPosition]
return oldItem == newItem
}
/**
* Returns true if the contents of the items are the same.
*/
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return true
}
/**
* Returns the size of the old list.
*/
override fun getOldListSize(): Int {
return oldItems.size
}
/**
* Returns the size of the new list.
*/
override fun getNewListSize(): Int {
return newItems.size
}
}
private companion object {
/**
* View holder type of a chapter page view.
*/
const val PAGE_VIEW = 0
/**
* View holder type of a chapter transition view.
*/
const val TRANSITION_VIEW = 1
} }
} }

View File

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

View File

@ -0,0 +1,68 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import com.f2prateek.rx.preferences.Preference
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.addTo
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Configuration used by webtoon viewers.
*/
class WebtoonConfig(preferences: PreferencesHelper = Injekt.get()) {
private val subscriptions = CompositeSubscription()
var imagePropertyChangedListener: (() -> Unit)? = null
var tappingEnabled = true
private set
var volumeKeysEnabled = false
private set
var volumeKeysInverted = false
private set
var imageCropBorders = false
private set
var doubleTapAnimDuration = 500
private set
init {
preferences.readWithTapping()
.register({ tappingEnabled = it })
preferences.cropBordersWebtoon()
.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
preferences.doubleTapAnimSpeed()
.register({ doubleTapAnimDuration = it })
preferences.readWithVolumeKeys()
.register({ volumeKeysEnabled = it })
preferences.readWithVolumeKeysInverted()
.register({ volumeKeysInverted = it })
}
fun unsubscribe() {
subscriptions.unsubscribe()
}
private fun <T> Preference<T>.register(
valueAssignment: (T) -> Unit,
onChanged: (T) -> Unit = {}
) {
asObservable()
.doOnNext(valueAssignment)
.skip(1)
.distinctUntilChanged()
.doOnNext(onChanged)
.subscribe()
.addTo(subscriptions)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -54,14 +54,6 @@ class SettingsReaderController : SettingsController() {
defaultValue = "0" defaultValue = "0"
summary = "%s" 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 { intListPreference {
key = Keys.doubleTapAnimationSpeed key = Keys.doubleTapAnimationSpeed
titleRes = R.string.pref_double_tap_anim_speed titleRes = R.string.pref_double_tap_anim_speed

View File

@ -11,6 +11,7 @@ import android.content.pm.PackageManager
import android.content.res.Resources import android.content.res.Resources
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.os.PowerManager import android.os.PowerManager
import android.support.annotation.AttrRes
import android.support.annotation.StringRes import android.support.annotation.StringRes
import android.support.v4.app.NotificationCompat import android.support.v4.app.NotificationCompat
import android.support.v4.content.ContextCompat import android.support.v4.content.ContextCompat
@ -79,7 +80,7 @@ fun Context.hasPermission(permission: String)
* *
* @param resource the attribute. * @param resource the attribute.
*/ */
fun Context.getResourceColor(@StringRes resource: Int): Int { fun Context.getResourceColor(@AttrRes resource: Int): Int {
val typedArray = obtainStyledAttributes(intArrayOf(resource)) val typedArray = obtainStyledAttributes(intArrayOf(resource))
val attrValue = typedArray.getColor(0, 0) val attrValue = typedArray.getColor(0, 0)
typedArray.recycle() typedArray.recycle()

View File

@ -8,47 +8,9 @@ import android.os.Environment
import android.support.v4.content.ContextCompat import android.support.v4.content.ContextCompat
import android.support.v4.os.EnvironmentCompat import android.support.v4.os.EnvironmentCompat
import java.io.File import java.io.File
import java.io.InputStream
import java.net.URLConnection
object DiskUtil { 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 { fun hashKeyForDisk(key: String): String {
return Hash.md5(key) return Hash.md5(key)
} }

View File

@ -0,0 +1,117 @@
package eu.kanade.tachiyomi.util
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import java.io.Closeable
import java.io.File
import java.io.InputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Wrapper over ZipFile to load files in epub format.
*/
class EpubFile(file: File) : Closeable {
/**
* Zip file of this epub.
*/
private val zip = ZipFile(file)
/**
* Closes the underlying zip file.
*/
override fun close() {
zip.close()
}
/**
* Returns an input stream for reading the contents of the specified zip file entry.
*/
fun getInputStream(entry: ZipEntry): InputStream {
return zip.getInputStream(entry)
}
/**
* Returns the zip file entry for the specified name, or null if not found.
*/
fun getEntry(name: String): ZipEntry? {
return zip.getEntry(name)
}
/**
* Returns the path of all the images found in the epub file.
*/
fun getImagesFromPages(): List<String> {
val allEntries = zip.entries().toList()
val ref = getPackageHref()
val doc = getPackageDocument(ref)
val pages = getPagesFromDocument(doc)
val hrefs = getHrefMap(ref, allEntries.map { it.name })
return getImagesFromPages(pages, hrefs)
}
/**
* Returns the path to the package document.
*/
private fun getPackageHref(): String {
val meta = zip.getEntry("META-INF/container.xml")
if (meta != null) {
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
if (path != null) {
return path
}
}
return "OEBPS/content.opf"
}
/**
* Returns the package document where all the files are listed.
*/
private fun getPackageDocument(ref: String): Document {
val entry = zip.getEntry(ref)
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
}
/**
* Returns all the pages from the epub.
*/
private fun getPagesFromDocument(document: Document): List<String> {
val pages = document.select("manifest > item")
.filter { "application/xhtml+xml" == it.attr("media-type") }
.associateBy { it.attr("id") }
val spine = document.select("spine > itemref").map { it.attr("idref") }
return spine.mapNotNull { pages[it] }.map { it.attr("href") }
}
/**
* Returns all the images contained in every page from the epub.
*/
private fun getImagesFromPages(pages: List<String>, hrefs: Map<String, String>): List<String> {
return pages.map { page ->
val entry = zip.getEntry(hrefs[page])
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
document.getElementsByTag("img").mapNotNull { hrefs[it.attr("src")] }
}.flatten()
}
/**
* Returns a map with a relative url as key and abolute url as path.
*/
private fun getHrefMap(packageHref: String, entries: List<String>): Map<String, String> {
val lastSlashPos = packageHref.lastIndexOf('/')
if (lastSlashPos < 0) {
return entries.associateBy { it }
}
return entries.associateBy { entry ->
if (entry.isNotBlank() && entry.length > lastSlashPos) {
entry.substring(lastSlashPos + 1)
} else {
entry
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More