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:
parent
7c99ae1b3b
commit
18f89cc341
@ -102,7 +102,7 @@ android {
|
||||
dependencies {
|
||||
|
||||
// Modified dependencies
|
||||
implementation 'com.github.inorichi:subsampling-scale-image-view:81b9d68'
|
||||
implementation('com.github.inorichi:subsampling-scale-image-view:caad3e4')
|
||||
implementation 'com.github.inorichi:junrar-android:634c1f5'
|
||||
|
||||
// Android support library
|
||||
@ -116,7 +116,7 @@ dependencies {
|
||||
implementation "com.android.support:support-annotations:$support_library_version"
|
||||
implementation "com.android.support:customtabs:$support_library_version"
|
||||
|
||||
implementation 'com.android.support.constraint:constraint-layout:1.1.0-beta6'
|
||||
implementation 'com.android.support.constraint:constraint-layout:1.1.2'
|
||||
|
||||
implementation 'com.android.support:multidex:1.0.2'
|
||||
|
||||
@ -201,6 +201,8 @@ dependencies {
|
||||
implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4'
|
||||
implementation 'com.github.mthli:Slice:v1.2'
|
||||
implementation 'me.gujun.android.taggroup:library:1.4@aar'
|
||||
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
|
||||
implementation 'com.github.inorichi:DirectionalViewPager:3acc51a'
|
||||
|
||||
// Conductor
|
||||
implementation "com.github.inorichi.Conductor:conductor:be8b3c5"
|
||||
@ -235,7 +237,7 @@ dependencies {
|
||||
}
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.2.30'
|
||||
ext.kotlin_version = '1.2.60'
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
@ -32,8 +32,7 @@
|
||||
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.reader.ReaderActivity"
|
||||
android:theme="@style/Theme.Reader" />
|
||||
android:name=".ui.reader.ReaderActivity" />
|
||||
<activity
|
||||
android:name=".widget.CustomLayoutPickerActivity"
|
||||
android:label="@string/app_name"
|
||||
@ -66,16 +65,6 @@
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
android:name="eu.kanade.tachiyomi.util.ZipContentProvider"
|
||||
android:authorities="${applicationId}.zip-provider"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="eu.kanade.tachiyomi.util.RarContentProvider"
|
||||
android:authorities="${applicationId}.rar-provider"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".data.notification.NotificationReceiver"
|
||||
android:exported="false" />
|
||||
|
@ -23,10 +23,12 @@ import java.util.concurrent.TimeUnit
|
||||
* @param sourceManager the source manager.
|
||||
* @param preferences the preferences of the app.
|
||||
*/
|
||||
class DownloadCache(private val context: Context,
|
||||
class DownloadCache(
|
||||
private val context: Context,
|
||||
private val provider: DownloadProvider,
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val preferences: PreferencesHelper = Injekt.get()) {
|
||||
private val sourceManager: SourceManager,
|
||||
private val preferences: PreferencesHelper = Injekt.get()
|
||||
) {
|
||||
|
||||
/**
|
||||
* The interval after which this cache should be invalidated. 1 hour shouldn't cause major
|
||||
@ -194,6 +196,24 @@ class DownloadCache(private val context: Context,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a list of chapters that have been deleted from this cache.
|
||||
*
|
||||
* @param chapters the list of chapter to remove.
|
||||
* @param manga the manga of the chapter.
|
||||
*/
|
||||
@Synchronized
|
||||
fun removeChapters(chapters: List<Chapter>, manga: Manga) {
|
||||
val sourceDir = rootDir.files[manga.source] ?: return
|
||||
val mangaDir = sourceDir.files[provider.getMangaDirName(manga)] ?: return
|
||||
for (chapter in chapters) {
|
||||
val chapterDirName = provider.getChapterDirName(chapter)
|
||||
if (chapterDirName in mangaDir.files) {
|
||||
mangaDir.files -= chapterDirName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a manga that has been deleted from this cache.
|
||||
*
|
||||
|
@ -7,8 +7,10 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* This class is used to manage chapter downloads in the application. It must be instantiated once
|
||||
@ -19,6 +21,11 @@ import rx.Observable
|
||||
*/
|
||||
class DownloadManager(context: Context) {
|
||||
|
||||
/**
|
||||
* The sources manager.
|
||||
*/
|
||||
private val sourceManager by injectLazy<SourceManager>()
|
||||
|
||||
/**
|
||||
* Downloads provider, used to retrieve the folders where the chapters are or should be stored.
|
||||
*/
|
||||
@ -27,12 +34,17 @@ class DownloadManager(context: Context) {
|
||||
/**
|
||||
* Cache of downloaded chapters.
|
||||
*/
|
||||
private val cache = DownloadCache(context, provider)
|
||||
private val cache = DownloadCache(context, provider, sourceManager)
|
||||
|
||||
/**
|
||||
* Downloader whose only task is to download chapters.
|
||||
*/
|
||||
private val downloader = Downloader(context, provider, cache)
|
||||
private val downloader = Downloader(context, provider, cache, sourceManager)
|
||||
|
||||
/**
|
||||
* Queue to delay the deletion of a list of chapters until triggered.
|
||||
*/
|
||||
private val pendingDeleter = DownloadPendingDeleter(context)
|
||||
|
||||
/**
|
||||
* Downloads queue, where the pending chapters are stored.
|
||||
@ -146,15 +158,20 @@ class DownloadManager(context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the directory of a downloaded chapter.
|
||||
* Deletes the directories of a list of downloaded chapters.
|
||||
*
|
||||
* @param chapter the chapter to delete.
|
||||
* @param manga the manga of the chapter.
|
||||
* @param source the source of the chapter.
|
||||
* @param chapters the list of chapters to delete.
|
||||
* @param manga the manga of the chapters.
|
||||
* @param source the source of the chapters.
|
||||
*/
|
||||
fun deleteChapter(chapter: Chapter, manga: Manga, source: Source) {
|
||||
provider.findChapterDir(chapter, manga, source)?.delete()
|
||||
cache.removeChapter(chapter, manga)
|
||||
fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source) {
|
||||
queue.remove(chapters)
|
||||
val chapterDirs = provider.findChapterDirs(chapters, manga, source)
|
||||
chapterDirs.forEach { it.delete() }
|
||||
cache.removeChapters(chapters, manga)
|
||||
if (cache.getDownloadCount(manga) == 0) { // Delete manga directory if empty
|
||||
chapterDirs.firstOrNull()?.parentFile?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -164,7 +181,30 @@ class DownloadManager(context: Context) {
|
||||
* @param source the source of the manga.
|
||||
*/
|
||||
fun deleteManga(manga: Manga, source: Source) {
|
||||
queue.remove(manga)
|
||||
provider.findMangaDir(manga, source)?.delete()
|
||||
cache.removeManga(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a list of chapters to be deleted later.
|
||||
*
|
||||
* @param chapters the list of chapters to delete.
|
||||
* @param manga the manga of the chapters.
|
||||
*/
|
||||
fun enqueueDeleteChapters(chapters: List<Chapter>, manga: Manga) {
|
||||
pendingDeleter.addChapters(chapters, manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers the execution of the deletion of pending chapters.
|
||||
*/
|
||||
fun deletePendingChapters() {
|
||||
val pendingChapters = pendingDeleter.getPendingChapters()
|
||||
for ((manga, chapters) in pendingChapters) {
|
||||
val source = sourceManager.get(manga.source) ?: continue
|
||||
deleteChapters(chapters, manga, source)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,180 @@
|
||||
package eu.kanade.tachiyomi.data.download
|
||||
|
||||
import android.content.Context
|
||||
import com.github.salomonbrys.kotson.fromJson
|
||||
import com.google.gson.Gson
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* Class used to keep a list of chapters for future deletion.
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
class DownloadPendingDeleter(context: Context) {
|
||||
|
||||
/**
|
||||
* Gson instance to encode and decode chapters.
|
||||
*/
|
||||
private val gson by injectLazy<Gson>()
|
||||
|
||||
/**
|
||||
* Preferences used to store the list of chapters to delete.
|
||||
*/
|
||||
private val prefs = context.getSharedPreferences("chapters_to_delete", Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Last added chapter, used to avoid decoding from the preference too often.
|
||||
*/
|
||||
private var lastAddedEntry: Entry? = null
|
||||
|
||||
/**
|
||||
* Adds a list of chapters for future deletion.
|
||||
*
|
||||
* @param chapters the chapters to be deleted.
|
||||
* @param manga the manga of the chapters.
|
||||
*/
|
||||
@Synchronized
|
||||
fun addChapters(chapters: List<Chapter>, manga: Manga) {
|
||||
val lastEntry = lastAddedEntry
|
||||
|
||||
val newEntry = if (lastEntry != null && lastEntry.manga.id == manga.id) {
|
||||
// Append new chapters
|
||||
val newChapters = lastEntry.chapters.addUniqueById(chapters)
|
||||
|
||||
// If no chapters were added, do nothing
|
||||
if (newChapters.size == lastEntry.chapters.size) return
|
||||
|
||||
// Last entry matches the manga, reuse it to avoid decoding json from preferences
|
||||
lastEntry.copy(chapters = newChapters)
|
||||
} else {
|
||||
val existingEntry = prefs.getString(manga.id!!.toString(), null)
|
||||
if (existingEntry != null) {
|
||||
// Existing entry found on preferences, decode json and add the new chapter
|
||||
val savedEntry = gson.fromJson<Entry>(existingEntry)
|
||||
|
||||
// Append new chapters
|
||||
val newChapters = savedEntry.chapters.addUniqueById(chapters)
|
||||
|
||||
// If no chapters were added, do nothing
|
||||
if (newChapters.size == savedEntry.chapters.size) return
|
||||
|
||||
savedEntry.copy(chapters = newChapters)
|
||||
} else {
|
||||
// No entry has been found yet, create a new one
|
||||
Entry(chapters.map { it.toEntry() }, manga.toEntry())
|
||||
}
|
||||
}
|
||||
|
||||
// Save current state
|
||||
val json = gson.toJson(newEntry)
|
||||
prefs.edit().putString(newEntry.manga.id.toString(), json).apply()
|
||||
lastAddedEntry = newEntry
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of chapters to be deleted grouped by its manga.
|
||||
*
|
||||
* Note: the returned list of manga and chapters only contain basic information needed by the
|
||||
* downloader, so don't use them for anything else.
|
||||
*/
|
||||
@Synchronized
|
||||
fun getPendingChapters(): Map<Manga, List<Chapter>> {
|
||||
val entries = decodeAll()
|
||||
prefs.edit().clear().apply()
|
||||
lastAddedEntry = null
|
||||
|
||||
return entries.associate { entry ->
|
||||
entry.manga.toModel() to entry.chapters.map { it.toModel() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes all the chapters from preferences.
|
||||
*/
|
||||
private fun decodeAll(): List<Entry> {
|
||||
return prefs.all.values.mapNotNull { rawEntry ->
|
||||
try {
|
||||
(rawEntry as? String)?.let { gson.fromJson<Entry>(it) }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of chapter entries ensuring no duplicates by chapter id.
|
||||
*/
|
||||
private fun List<ChapterEntry>.addUniqueById(chapters: List<Chapter>): List<ChapterEntry> {
|
||||
val newList = toMutableList()
|
||||
for (chapter in chapters) {
|
||||
if (none { it.id == chapter.id }) {
|
||||
newList.add(chapter.toEntry())
|
||||
}
|
||||
}
|
||||
return newList
|
||||
}
|
||||
|
||||
/**
|
||||
* Class used to save an entry of chapters with their manga into preferences.
|
||||
*/
|
||||
private data class Entry(
|
||||
val chapters: List<ChapterEntry>,
|
||||
val manga: MangaEntry
|
||||
)
|
||||
|
||||
/**
|
||||
* Class used to save an entry for a chapter into preferences.
|
||||
*/
|
||||
private data class ChapterEntry(
|
||||
val id: Long,
|
||||
val url: String,
|
||||
val name: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Class used to save an entry for a manga into preferences.
|
||||
*/
|
||||
private data class MangaEntry(
|
||||
val id: Long,
|
||||
val url: String,
|
||||
val title: String,
|
||||
val source: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns a manga entry from a manga model.
|
||||
*/
|
||||
private fun Manga.toEntry(): MangaEntry {
|
||||
return MangaEntry(id!!, url, title, source)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a chapter entry from a chapter model.
|
||||
*/
|
||||
private fun Chapter.toEntry(): ChapterEntry {
|
||||
return ChapterEntry(id!!, url, name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a manga model from a manga entry.
|
||||
*/
|
||||
private fun MangaEntry.toModel(): Manga {
|
||||
return Manga.create(url, title, source).also {
|
||||
it.id = id
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a chapter model from a chapter entry.
|
||||
*/
|
||||
private fun ChapterEntry.toModel(): Chapter {
|
||||
return Chapter.create().also {
|
||||
it.id = id
|
||||
it.url = url
|
||||
it.name = name
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -81,6 +81,18 @@ class DownloadProvider(private val context: Context) {
|
||||
return mangaDir?.findFile(getChapterDirName(chapter))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of downloaded directories for the chapters that exist.
|
||||
*
|
||||
* @param chapters the chapters to query.
|
||||
* @param manga the manga of the chapter.
|
||||
* @param source the source of the chapter.
|
||||
*/
|
||||
fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): List<UniFile> {
|
||||
val mangaDir = findMangaDir(manga, source) ?: return emptyList()
|
||||
return chapters.mapNotNull { mangaDir.findFile(getChapterDirName(it)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the download directory name for a source.
|
||||
*
|
||||
|
@ -14,7 +14,10 @@ import uy.kohesive.injekt.injectLazy
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
class DownloadStore(context: Context) {
|
||||
class DownloadStore(
|
||||
context: Context,
|
||||
private val sourceManager: SourceManager
|
||||
) {
|
||||
|
||||
/**
|
||||
* Preference file where active downloads are stored.
|
||||
@ -26,11 +29,6 @@ class DownloadStore(context: Context) {
|
||||
*/
|
||||
private val gson: Gson by injectLazy()
|
||||
|
||||
/**
|
||||
* Source manager.
|
||||
*/
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
/**
|
||||
* Database helper.
|
||||
*/
|
||||
@ -83,7 +81,7 @@ class DownloadStore(context: Context) {
|
||||
fun restore(): List<Download> {
|
||||
val objs = preferences.all
|
||||
.mapNotNull { it.value as? String }
|
||||
.map { deserialize(it) }
|
||||
.mapNotNull { deserialize(it) }
|
||||
.sortedBy { it.order }
|
||||
|
||||
val downloads = mutableListOf<Download>()
|
||||
@ -119,8 +117,12 @@ class DownloadStore(context: Context) {
|
||||
*
|
||||
* @param string the download as string.
|
||||
*/
|
||||
private fun deserialize(string: String): DownloadObject {
|
||||
return gson.fromJson(string, DownloadObject::class.java)
|
||||
private fun deserialize(string: String): DownloadObject? {
|
||||
return try {
|
||||
gson.fromJson(string, DownloadObject::class.java)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -21,7 +21,6 @@ import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* This class is the one in charge of downloading chapters.
|
||||
@ -35,28 +34,25 @@ import uy.kohesive.injekt.injectLazy
|
||||
* @param context the application context.
|
||||
* @param provider the downloads directory provider.
|
||||
* @param cache the downloads cache, used to add the downloads to the cache after their completion.
|
||||
* @param sourceManager the source manager.
|
||||
*/
|
||||
class Downloader(
|
||||
private val context: Context,
|
||||
private val provider: DownloadProvider,
|
||||
private val cache: DownloadCache
|
||||
private val cache: DownloadCache,
|
||||
private val sourceManager: SourceManager
|
||||
) {
|
||||
|
||||
/**
|
||||
* Store for persisting downloads across restarts.
|
||||
*/
|
||||
private val store = DownloadStore(context)
|
||||
private val store = DownloadStore(context, sourceManager)
|
||||
|
||||
/**
|
||||
* Queue where active downloads are kept.
|
||||
*/
|
||||
val queue = DownloadQueue(store)
|
||||
|
||||
/**
|
||||
* Source manager.
|
||||
*/
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
/**
|
||||
* Notifier for the downloader state and progress.
|
||||
*/
|
||||
@ -382,7 +378,7 @@ class Downloader(
|
||||
// Else guess from the uri.
|
||||
?: context.contentResolver.getType(file.uri)
|
||||
// Else read magic numbers.
|
||||
?: DiskUtil.findImageMime { file.openInputStream() }
|
||||
?: ImageUtil.findImageType { file.openInputStream() }?.mime
|
||||
|
||||
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.download.model
|
||||
|
||||
import com.jakewharton.rxrelay.PublishRelay
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.DownloadStore
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import rx.Observable
|
||||
@ -40,6 +41,14 @@ class DownloadQueue(
|
||||
find { it.chapter.id == chapter.id }?.let { remove(it) }
|
||||
}
|
||||
|
||||
fun remove(chapters: List<Chapter>) {
|
||||
for (chapter in chapters) { remove(chapter) }
|
||||
}
|
||||
|
||||
fun remove(manga: Manga) {
|
||||
filter { it.manga.id == manga.id }.forEach { remove(it) }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
queue.forEach { download ->
|
||||
download.setStatusSubject(null)
|
||||
|
@ -0,0 +1,74 @@
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
class PassthroughModelLoader : ModelLoader<InputStream, InputStream> {
|
||||
|
||||
override fun buildLoadData(
|
||||
model: InputStream,
|
||||
width: Int,
|
||||
height: Int,
|
||||
options: Options
|
||||
): ModelLoader.LoadData<InputStream>? {
|
||||
return ModelLoader.LoadData(ObjectKey(model), Fetcher(model))
|
||||
}
|
||||
|
||||
override fun handles(model: InputStream): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
class Fetcher(private val stream: InputStream) : DataFetcher<InputStream> {
|
||||
|
||||
override fun getDataClass(): Class<InputStream> {
|
||||
return InputStream::class.java
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
try {
|
||||
stream.close()
|
||||
} catch (e: IOException) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDataSource(): DataSource {
|
||||
return DataSource.LOCAL
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
override fun loadData(
|
||||
priority: Priority,
|
||||
callback: DataFetcher.DataCallback<in InputStream>
|
||||
) {
|
||||
callback.onDataReady(stream)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory class for creating [PassthroughModelLoader] instances.
|
||||
*/
|
||||
class Factory : ModelLoaderFactory<InputStream, InputStream> {
|
||||
|
||||
override fun build(
|
||||
multiFactory: MultiModelLoaderFactory
|
||||
): ModelLoader<InputStream, InputStream> {
|
||||
return PassthroughModelLoader()
|
||||
}
|
||||
|
||||
override fun teardown() {}
|
||||
}
|
||||
|
||||
}
|
@ -37,5 +37,7 @@ class TachiGlideModule : AppGlideModule() {
|
||||
|
||||
registry.replace(GlideUrl::class.java, InputStream::class.java, networkFactory)
|
||||
registry.append(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory())
|
||||
registry.append(InputStream::class.java, InputStream::class.java, PassthroughModelLoader
|
||||
.Factory())
|
||||
}
|
||||
}
|
||||
|
@ -31,8 +31,6 @@ object PreferenceKeys {
|
||||
|
||||
const val imageScaleType = "pref_image_scale_type_key"
|
||||
|
||||
const val imageDecoder = "image_decoder"
|
||||
|
||||
const val zoomStart = "pref_zoom_start_key"
|
||||
|
||||
const val readerTheme = "pref_reader_theme_key"
|
||||
|
@ -59,8 +59,6 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
|
||||
|
||||
fun imageDecoder() = rxPrefs.getInteger(Keys.imageDecoder, 0)
|
||||
|
||||
fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1)
|
||||
|
||||
fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0)
|
||||
|
@ -1,24 +1,22 @@
|
||||
package eu.kanade.tachiyomi.source
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.*
|
||||
import eu.kanade.tachiyomi.util.ChapterRecognition
|
||||
import eu.kanade.tachiyomi.util.DiskUtil
|
||||
import eu.kanade.tachiyomi.util.RarContentProvider
|
||||
import eu.kanade.tachiyomi.util.ZipContentProvider
|
||||
import eu.kanade.tachiyomi.util.EpubFile
|
||||
import eu.kanade.tachiyomi.util.ImageUtil
|
||||
import junrar.Archive
|
||||
import junrar.rarfile.FileHeader
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import rx.Observable
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.InputStream
|
||||
import java.util.*
|
||||
import java.util.Comparator
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
@ -107,18 +105,14 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
||||
if (thumbnail_url == null) {
|
||||
val chapters = fetchChapterList(this).toBlocking().first()
|
||||
if (chapters.isNotEmpty()) {
|
||||
val uri = fetchPageList(chapters.last()).toBlocking().first().firstOrNull()?.uri
|
||||
if (uri != null) {
|
||||
val input = context.contentResolver.openInputStream(uri)
|
||||
try {
|
||||
val dest = updateCover(context, this, input)
|
||||
val dest = updateCover(chapters.last(), this)
|
||||
thumbnail_url = dest?.absolutePath
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initialized = true
|
||||
}
|
||||
@ -135,7 +129,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
||||
val chapters = getBaseDirectories(context)
|
||||
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
||||
.flatten()
|
||||
.filter { it.isDirectory || isSupportedFormat(it.extension) }
|
||||
.filter { it.isDirectory || isSupportedFile(it.extension) }
|
||||
.map { chapterFile ->
|
||||
SChapter.create().apply {
|
||||
url = "${manga.url}/${chapterFile.name}"
|
||||
@ -150,7 +144,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
||||
ChapterRecognition.parseChapterNumber(this, manga)
|
||||
}
|
||||
}
|
||||
.sortedWith(Comparator<SChapter> { c1, c2 ->
|
||||
.sortedWith(Comparator { c1, c2 ->
|
||||
val c = c2.chapter_number.compareTo(c1.chapter_number)
|
||||
if (c == 0) comparator.compare(c2.name, c1.name) else c
|
||||
})
|
||||
@ -159,160 +153,90 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
return Observable.error(Exception("Unused"))
|
||||
}
|
||||
|
||||
private fun isSupportedFile(extension: String): Boolean {
|
||||
return extension.toLowerCase() in setOf("zip", "rar", "cbr", "cbz", "epub")
|
||||
}
|
||||
|
||||
fun getFormat(chapter: SChapter): Format {
|
||||
val baseDirs = getBaseDirectories(context)
|
||||
|
||||
for (dir in baseDirs) {
|
||||
val chapFile = File(dir, chapter.url)
|
||||
if (!chapFile.exists()) continue
|
||||
|
||||
return Observable.just(getLoader(chapFile).load())
|
||||
return getFormat(chapFile)
|
||||
}
|
||||
throw Exception("Chapter not found")
|
||||
}
|
||||
|
||||
return Observable.error(Exception("Chapter not found"))
|
||||
}
|
||||
|
||||
private fun isSupportedFormat(extension: String): Boolean {
|
||||
return extension.equals("zip", true) || extension.equals("cbz", true)
|
||||
|| extension.equals("rar", true) || extension.equals("cbr", true)
|
||||
|| extension.equals("epub", true)
|
||||
}
|
||||
|
||||
private fun getLoader(file: File): Loader {
|
||||
private fun getFormat(file: File): Format {
|
||||
val extension = file.extension
|
||||
return if (file.isDirectory) {
|
||||
DirectoryLoader(file)
|
||||
Format.Directory(file)
|
||||
} else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
|
||||
ZipLoader(file)
|
||||
} else if (extension.equals("epub", true)) {
|
||||
EpubLoader(file)
|
||||
Format.Zip(file)
|
||||
} else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
|
||||
RarLoader(file)
|
||||
Format.Rar(file)
|
||||
} else if (extension.equals("epub", true)) {
|
||||
Format.Epub(file)
|
||||
} else {
|
||||
throw Exception("Invalid chapter format")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCover(chapter: SChapter, manga: SManga): File? {
|
||||
val format = getFormat(chapter)
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return when (format) {
|
||||
is Format.Directory -> {
|
||||
val entry = format.file.listFiles()
|
||||
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||
.find { !it.isDirectory && ImageUtil.isImage(it.name, { FileInputStream(it) }) }
|
||||
|
||||
entry?.let { updateCover(context, manga, it.inputStream())}
|
||||
}
|
||||
is Format.Zip -> {
|
||||
ZipFile(format.file).use { zip ->
|
||||
val entry = zip.entries().toList()
|
||||
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||
.find { !it.isDirectory && ImageUtil.isImage(it.name, { zip.getInputStream(it) }) }
|
||||
|
||||
entry?.let { updateCover(context, manga, zip.getInputStream(it) )}
|
||||
}
|
||||
}
|
||||
is Format.Rar -> {
|
||||
Archive(format.file).use { archive ->
|
||||
val entry = archive.fileHeaders
|
||||
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
|
||||
.find { !it.isDirectory && ImageUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
|
||||
|
||||
entry?.let { updateCover(context, manga, archive.getInputStream(it) )}
|
||||
}
|
||||
}
|
||||
is Format.Epub -> {
|
||||
EpubFile(format.file).use { epub ->
|
||||
val entry = epub.getImagesFromPages()
|
||||
.firstOrNull()
|
||||
?.let { epub.getEntry(it) }
|
||||
|
||||
entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true))
|
||||
|
||||
override fun getFilterList() = FilterList(OrderBy())
|
||||
|
||||
interface Loader {
|
||||
fun load(): List<Page>
|
||||
sealed class Format {
|
||||
data class Directory(val file: File) : Format()
|
||||
data class Zip(val file: File) : Format()
|
||||
data class Rar(val file: File): Format()
|
||||
data class Epub(val file: File) : Format()
|
||||
}
|
||||
|
||||
class DirectoryLoader(val file: File) : Loader {
|
||||
override fun load(): List<Page> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return file.listFiles()
|
||||
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { FileInputStream(it) }) }
|
||||
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||
.map { Uri.fromFile(it) }
|
||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
||||
}
|
||||
}
|
||||
|
||||
class ZipLoader(val file: File) : Loader {
|
||||
override fun load(): List<Page> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return ZipFile(file).use { zip ->
|
||||
zip.entries().toList()
|
||||
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { zip.getInputStream(it) }) }
|
||||
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/${it.name}") }
|
||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RarLoader(val file: File) : Loader {
|
||||
override fun load(): List<Page> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return Archive(file).use { archive ->
|
||||
archive.fileHeaders
|
||||
.filter { !it.isDirectory && DiskUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
|
||||
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
|
||||
.map { Uri.parse("content://${RarContentProvider.PROVIDER}${file.absolutePath}!-/${it.fileNameString}") }
|
||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EpubLoader(val file: File) : Loader {
|
||||
|
||||
override fun load(): List<Page> {
|
||||
ZipFile(file).use { zip ->
|
||||
val allEntries = zip.entries().toList()
|
||||
val ref = getPackageHref(zip)
|
||||
val doc = getPackageDocument(zip, ref)
|
||||
val pages = getPagesFromDocument(doc)
|
||||
val hrefs = getHrefMap(ref, allEntries.map { it.name })
|
||||
return getImagesFromPages(zip, pages, hrefs)
|
||||
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/$it") }
|
||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the package document.
|
||||
*/
|
||||
private fun getPackageHref(zip: ZipFile): String {
|
||||
val meta = zip.getEntry("META-INF/container.xml")
|
||||
if (meta != null) {
|
||||
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
|
||||
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
|
||||
if (path != null) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return "OEBPS/content.opf"
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the package document where all the files are listed.
|
||||
*/
|
||||
private fun getPackageDocument(zip: ZipFile, ref: String): Document {
|
||||
val entry = zip.getEntry(ref)
|
||||
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the pages from the epub.
|
||||
*/
|
||||
private fun getPagesFromDocument(document: Document): List<String> {
|
||||
val pages = document.select("manifest > item")
|
||||
.filter { "application/xhtml+xml" == it.attr("media-type") }
|
||||
.associateBy { it.attr("id") }
|
||||
|
||||
val spine = document.select("spine > itemref").map { it.attr("idref") }
|
||||
return spine.mapNotNull { pages[it] }.map { it.attr("href") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the images contained in every page from the epub.
|
||||
*/
|
||||
private fun getImagesFromPages(zip: ZipFile, pages: List<String>, hrefs: Map<String, String>): List<String> {
|
||||
return pages.map { page ->
|
||||
val entry = zip.getEntry(hrefs[page])
|
||||
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
||||
document.getElementsByTag("img").mapNotNull { hrefs[it.attr("src")] }
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map with a relative url as key and abolute url as path.
|
||||
*/
|
||||
private fun getHrefMap(packageHref: String, entries: List<String>): Map<String, String> {
|
||||
val lastSlashPos = packageHref.lastIndexOf('/')
|
||||
if (lastSlashPos < 0) {
|
||||
return entries.associateBy { it }
|
||||
}
|
||||
return entries.associateBy { entry ->
|
||||
if (entry.isNotBlank() && entry.length > lastSlashPos) {
|
||||
entry.substring(lastSlashPos + 1)
|
||||
} else {
|
||||
entry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -2,21 +2,18 @@ package eu.kanade.tachiyomi.source.model
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.network.ProgressListener
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
|
||||
import rx.subjects.Subject
|
||||
|
||||
class Page(
|
||||
open class Page(
|
||||
val index: Int,
|
||||
val url: String = "",
|
||||
var imageUrl: String? = null,
|
||||
@Transient var uri: Uri? = null
|
||||
@Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
|
||||
) : ProgressListener {
|
||||
|
||||
val number: Int
|
||||
get() = index + 1
|
||||
|
||||
@Transient lateinit var chapter: ReaderChapter
|
||||
|
||||
@Transient @Volatile var status: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
|
@ -1,43 +1,7 @@
|
||||
package eu.kanade.tachiyomi.source.online
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
|
||||
// TODO: this should be handled with a different approach.
|
||||
|
||||
/**
|
||||
* Chapter cache.
|
||||
*/
|
||||
private val chapterCache: ChapterCache by injectLazy()
|
||||
|
||||
/**
|
||||
* Returns an observable with the page list for a chapter. It tries to return the page list from
|
||||
* the local cache, otherwise fallbacks to network.
|
||||
*
|
||||
* @param chapter the chapter whose page list has to be fetched.
|
||||
*/
|
||||
fun HttpSource.fetchPageListFromCacheThenNet(chapter: Chapter): Observable<List<Page>> {
|
||||
return chapterCache
|
||||
.getPageListFromCache(chapter)
|
||||
.onErrorResumeNext { fetchPageList(chapter) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the page with the downloaded image.
|
||||
*
|
||||
* @param page the page whose source image has to be downloaded.
|
||||
*/
|
||||
fun HttpSource.fetchImageFromCacheThenNet(page: Page): Observable<Page> {
|
||||
return if (page.imageUrl.isNullOrEmpty())
|
||||
getImageUrl(page).flatMap { getCachedImage(it) }
|
||||
else
|
||||
getCachedImage(page)
|
||||
}
|
||||
|
||||
fun HttpSource.getImageUrl(page: Page): Observable<Page> {
|
||||
page.status = Page.LOAD_PAGE
|
||||
@ -48,43 +12,6 @@ fun HttpSource.getImageUrl(page: Page): Observable<Page> {
|
||||
.map { page }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the page that gets the image from the chapter or fallbacks to
|
||||
* network and copies it to the cache calling [cacheImage].
|
||||
*
|
||||
* @param page the page.
|
||||
*/
|
||||
fun HttpSource.getCachedImage(page: Page): Observable<Page> {
|
||||
val imageUrl = page.imageUrl ?: return Observable.just(page)
|
||||
|
||||
return Observable.just(page)
|
||||
.flatMap {
|
||||
if (!chapterCache.isImageInCache(imageUrl)) {
|
||||
cacheImage(page)
|
||||
} else {
|
||||
Observable.just(page)
|
||||
}
|
||||
}
|
||||
.doOnNext {
|
||||
page.uri = Uri.fromFile(chapterCache.getImageFile(imageUrl))
|
||||
page.status = Page.READY
|
||||
}
|
||||
.doOnError { page.status = Page.ERROR }
|
||||
.onErrorReturn { page }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the page that downloads the image to [ChapterCache].
|
||||
*
|
||||
* @param page the page.
|
||||
*/
|
||||
private fun HttpSource.cacheImage(page: Page): Observable<Page> {
|
||||
page.status = Page.DOWNLOAD_IMAGE
|
||||
return fetchImage(page)
|
||||
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
|
||||
.map { page }
|
||||
}
|
||||
|
||||
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
|
||||
return Observable.from(pages)
|
||||
.filter { !it.imageUrl.isNullOrEmpty() }
|
||||
|
@ -20,7 +20,7 @@ import rx.schedulers.Schedulers
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Presenter of [ChaptersController].
|
||||
@ -271,9 +271,8 @@ class ChaptersPresenter(
|
||||
* @param chapters the list of chapters to delete.
|
||||
*/
|
||||
fun deleteChapters(chapters: List<ChapterItem>) {
|
||||
Observable.from(chapters)
|
||||
.doOnNext { deleteChapter(it) }
|
||||
.toList()
|
||||
Observable.just(chapters)
|
||||
.doOnNext { deleteChaptersInternal(chapters) }
|
||||
.doOnNext { if (onlyDownloaded()) refreshChapters() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
@ -283,14 +282,15 @@ class ChaptersPresenter(
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a chapter from disk. This method is called in a background thread.
|
||||
* @param chapter the chapter to delete.
|
||||
* Deletes a list of chapters from disk. This method is called in a background thread.
|
||||
* @param chapters the chapters to delete.
|
||||
*/
|
||||
private fun deleteChapter(chapter: ChapterItem) {
|
||||
downloadManager.queue.remove(chapter)
|
||||
downloadManager.deleteChapter(chapter, manga, source)
|
||||
chapter.status = Download.NOT_DOWNLOADED
|
||||
chapter.download = null
|
||||
private fun deleteChaptersInternal(chapters: List<ChapterItem>) {
|
||||
downloadManager.deleteChapters(chapters, manga, source)
|
||||
chapters.forEach {
|
||||
it.status = Download.NOT_DOWNLOADED
|
||||
it.download = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -0,0 +1,36 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
|
||||
/**
|
||||
* Load strategy using the source order. This is the default ordering.
|
||||
*/
|
||||
class ChapterLoadBySource {
|
||||
fun get(allChapters: List<Chapter>): List<Chapter> {
|
||||
return allChapters.sortedByDescending { it.source_order }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load strategy using unique chapter numbers with same scanlator preference.
|
||||
*/
|
||||
class ChapterLoadByNumber {
|
||||
fun get(allChapters: List<Chapter>, selectedChapter: Chapter): List<Chapter> {
|
||||
val chapters = mutableListOf<Chapter>()
|
||||
val chaptersByNumber = allChapters.groupBy { it.chapter_number }
|
||||
|
||||
for ((number, chaptersForNumber) in chaptersByNumber) {
|
||||
val preferredChapter = when {
|
||||
// Make sure the selected chapter is always present
|
||||
number == selectedChapter.chapter_number -> selectedChapter
|
||||
// If there is only one chapter for this number, use it
|
||||
chaptersForNumber.size == 1 -> chaptersForNumber.first()
|
||||
// Prefer a chapter of the same scanlator as the selected
|
||||
else -> chaptersForNumber.find { it.scanlator == selectedChapter.scanlator }
|
||||
?: chaptersForNumber.first()
|
||||
}
|
||||
chapters.add(preferredChapter)
|
||||
}
|
||||
return chapters.sortedBy { it.chapter_number }
|
||||
}
|
||||
}
|
@ -1,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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -12,8 +12,13 @@ import android.text.style.ScaleXSpan
|
||||
import android.util.AttributeSet
|
||||
import android.widget.TextView
|
||||
|
||||
class PageIndicatorTextView(context: Context, attrs: AttributeSet? = null) :
|
||||
AppCompatTextView(context, attrs) {
|
||||
/**
|
||||
* Page indicator found at the bottom of the reader
|
||||
*/
|
||||
class PageIndicatorTextView(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : AppCompatTextView(context, attrs) {
|
||||
|
||||
private val fillColor = Color.rgb(235, 235, 235)
|
||||
private val strokeColor = Color.rgb(45, 45, 45)
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,13 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
|
||||
class ReaderChapter(c: Chapter) : Chapter by c {
|
||||
|
||||
@Transient var pages: List<Page>? = null
|
||||
|
||||
var isDownloaded: Boolean = false
|
||||
|
||||
var requestedPage: Int = 0
|
||||
}
|
@ -1,19 +1,19 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import android.app.Dialog
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.support.annotation.ColorInt
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.design.widget.BottomSheetBehavior
|
||||
import android.support.design.widget.BottomSheetDialog
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.SeekBar
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.util.plusAssign
|
||||
import eu.kanade.tachiyomi.widget.SimpleSeekBarListener
|
||||
import kotlinx.android.synthetic.main.reader_custom_filter_dialog.view.*
|
||||
import kotlinx.android.synthetic.main.reader_color_filter.*
|
||||
import kotlinx.android.synthetic.main.reader_color_filter_sheet.*
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
@ -21,33 +21,18 @@ import uy.kohesive.injekt.injectLazy
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Custom dialog which can be used to set overlay value's
|
||||
* Color filter sheet to toggle custom filter and brightness overlay.
|
||||
*/
|
||||
class ReaderCustomFilterDialog : DialogFragment() {
|
||||
class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activity) {
|
||||
|
||||
companion object {
|
||||
/** Integer mask of alpha value **/
|
||||
private const val ALPHA_MASK: Long = 0xFF000000
|
||||
|
||||
/** Integer mask of red value **/
|
||||
private const val RED_MASK: Long = 0x00FF0000
|
||||
|
||||
/** Integer mask of green value **/
|
||||
private const val GREEN_MASK: Long = 0x0000FF00
|
||||
|
||||
/** Integer mask of blue value **/
|
||||
private const val BLUE_MASK: Long = 0x000000FF
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides operations to manage preferences
|
||||
*/
|
||||
private val preferences by injectLazy<PreferencesHelper>()
|
||||
|
||||
private var behavior: BottomSheetBehavior<*>? = null
|
||||
|
||||
/**
|
||||
* Subscription used for filter overlay
|
||||
* Subscriptions used for this dialog
|
||||
*/
|
||||
private lateinit var subscriptions: CompositeSubscription
|
||||
private val subscriptions = CompositeSubscription()
|
||||
|
||||
/**
|
||||
* Subscription used for custom brightness overlay
|
||||
@ -59,28 +44,12 @@ class ReaderCustomFilterDialog : DialogFragment() {
|
||||
*/
|
||||
private var customFilterColorSubscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* This method will be called after onCreate(Bundle)
|
||||
* @param savedState The last saved instance state of the Fragment.
|
||||
*/
|
||||
override fun onCreateDialog(savedState: Bundle?): Dialog {
|
||||
val dialog = MaterialDialog.Builder(activity!!)
|
||||
.customView(R.layout.reader_custom_filter_dialog, false)
|
||||
.positiveText(android.R.string.ok)
|
||||
.build()
|
||||
init {
|
||||
val view = activity.layoutInflater.inflate(R.layout.reader_color_filter_sheet, null)
|
||||
setContentView(view)
|
||||
|
||||
subscriptions = CompositeSubscription()
|
||||
onViewCreated(dialog.view, savedState)
|
||||
behavior = BottomSheetBehavior.from(view.parent as ViewGroup)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after onCreateView()
|
||||
* @param view The View returned by onCreateDialog.
|
||||
* @param savedInstanceState If non-null, this fragment is being re-constructed
|
||||
*/
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(view) {
|
||||
// Initialize subscriptions.
|
||||
subscriptions += preferences.colorFilter().asObservable()
|
||||
.subscribe { setColorFilter(it, view) }
|
||||
@ -154,7 +123,19 @@ class ReaderCustomFilterDialog : DialogFragment() {
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
behavior?.skipCollapsed = true
|
||||
behavior?.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
subscriptions.unsubscribe()
|
||||
customBrightnessSubscription = null
|
||||
customFilterColorSubscription = null
|
||||
}
|
||||
|
||||
/**
|
||||
@ -255,7 +236,7 @@ class ReaderCustomFilterDialog : DialogFragment() {
|
||||
subscriptions.add(customFilterColorSubscription)
|
||||
} else {
|
||||
customFilterColorSubscription?.let { subscriptions.remove(it) }
|
||||
view.color_overlay.visibility = View.GONE
|
||||
color_overlay.visibility = View.GONE
|
||||
}
|
||||
setColorFilterSeekBar(enabled, view)
|
||||
}
|
||||
@ -319,12 +300,18 @@ class ReaderCustomFilterDialog : DialogFragment() {
|
||||
return color and 0xFF
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when dialog is dismissed
|
||||
*/
|
||||
override fun onDestroyView() {
|
||||
subscriptions.unsubscribe()
|
||||
super.onDestroyView()
|
||||
private companion object {
|
||||
/** Integer mask of alpha value **/
|
||||
const val ALPHA_MASK: Long = 0xFF000000
|
||||
|
||||
/** Integer mask of red value **/
|
||||
const val RED_MASK: Long = 0x00FF0000
|
||||
|
||||
/** Integer mask of green value **/
|
||||
const val GREEN_MASK: Long = 0x0000FF00
|
||||
|
||||
/** Integer mask of blue value **/
|
||||
const val BLUE_MASK: Long = 0x000000FF
|
||||
}
|
||||
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
|
||||
class ReaderEvent(val manga: Manga, val chapter: Chapter)
|
@ -0,0 +1,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()
|
||||
}
|
||||
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,45 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.support.v7.widget.AppCompatSeekBar
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
|
||||
/**
|
||||
* Seekbar to show current chapter progress.
|
||||
*/
|
||||
class ReaderSeekBar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : AppCompatSeekBar(context, attrs) {
|
||||
|
||||
/**
|
||||
* Whether the seekbar should draw from right to left.
|
||||
*/
|
||||
var isRTL = false
|
||||
|
||||
/**
|
||||
* Draws the seekbar, translating the canvas if using a right to left reader.
|
||||
*/
|
||||
override fun draw(canvas: Canvas) {
|
||||
if (isRTL) {
|
||||
val px = width / 2f
|
||||
val py = height / 2f
|
||||
|
||||
canvas.scale(-1f, 1f, px, py)
|
||||
}
|
||||
super.draw(canvas)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles touch events, translating coordinates if using a right to left reader.
|
||||
*/
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (isRTL) {
|
||||
event.setLocation(width - event.x, event.y)
|
||||
}
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
}
|
@ -1,119 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.view.View
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.util.plusAssign
|
||||
import eu.kanade.tachiyomi.util.visibleIf
|
||||
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
|
||||
import kotlinx.android.synthetic.main.reader_settings_dialog.view.*
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.concurrent.TimeUnit.MILLISECONDS
|
||||
|
||||
class ReaderSettingsDialog : DialogFragment() {
|
||||
|
||||
private val preferences by injectLazy<PreferencesHelper>()
|
||||
|
||||
private lateinit var subscriptions: CompositeSubscription
|
||||
|
||||
override fun onCreateDialog(savedState: Bundle?): Dialog {
|
||||
val dialog = MaterialDialog.Builder(activity!!)
|
||||
.title(R.string.label_settings)
|
||||
.customView(R.layout.reader_settings_dialog, true)
|
||||
.positiveText(android.R.string.ok)
|
||||
.build()
|
||||
|
||||
subscriptions = CompositeSubscription()
|
||||
onViewCreated(dialog.view, savedState)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedState: Bundle?) = with(view) {
|
||||
viewer.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
|
||||
subscriptions += Observable.timer(250, MILLISECONDS, AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
val readerActivity = activity as? ReaderActivity
|
||||
if (readerActivity != null) {
|
||||
readerActivity.presenter.updateMangaViewer(position)
|
||||
readerActivity.recreate()
|
||||
}
|
||||
}
|
||||
}
|
||||
viewer.setSelection((activity as ReaderActivity).presenter.manga.viewer, false)
|
||||
|
||||
rotation_mode.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
|
||||
subscriptions += Observable.timer(250, MILLISECONDS)
|
||||
.subscribe {
|
||||
preferences.rotation().set(position + 1)
|
||||
}
|
||||
}
|
||||
rotation_mode.setSelection(preferences.rotation().getOrDefault() - 1, false)
|
||||
|
||||
scale_type.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
|
||||
preferences.imageScaleType().set(position + 1)
|
||||
}
|
||||
scale_type.setSelection(preferences.imageScaleType().getOrDefault() - 1, false)
|
||||
|
||||
zoom_start.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
|
||||
preferences.zoomStart().set(position + 1)
|
||||
}
|
||||
zoom_start.setSelection(preferences.zoomStart().getOrDefault() - 1, false)
|
||||
|
||||
image_decoder.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
|
||||
preferences.imageDecoder().set(position)
|
||||
}
|
||||
image_decoder.setSelection(preferences.imageDecoder().getOrDefault(), false)
|
||||
|
||||
background_color.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
|
||||
preferences.readerTheme().set(position)
|
||||
}
|
||||
background_color.setSelection(preferences.readerTheme().getOrDefault(), false)
|
||||
|
||||
show_page_number.isChecked = preferences.showPageNumber().getOrDefault()
|
||||
show_page_number.setOnCheckedChangeListener { _, isChecked ->
|
||||
preferences.showPageNumber().set(isChecked)
|
||||
}
|
||||
|
||||
fullscreen.isChecked = preferences.fullscreen().getOrDefault()
|
||||
fullscreen.setOnCheckedChangeListener { _, isChecked ->
|
||||
preferences.fullscreen().set(isChecked)
|
||||
}
|
||||
|
||||
crop_borders.isChecked = preferences.cropBorders().getOrDefault()
|
||||
crop_borders.setOnCheckedChangeListener { _, isChecked ->
|
||||
preferences.cropBorders().set(isChecked)
|
||||
}
|
||||
|
||||
crop_borders_webtoon.isChecked = preferences.cropBordersWebtoon().getOrDefault()
|
||||
crop_borders_webtoon.setOnCheckedChangeListener { _, isChecked ->
|
||||
preferences.cropBordersWebtoon().set(isChecked)
|
||||
}
|
||||
|
||||
val readerActivity = activity as? ReaderActivity
|
||||
val isWebtoonViewer = if (readerActivity != null) {
|
||||
val mangaViewer = readerActivity.presenter.manga.viewer
|
||||
val viewer = if (mangaViewer == 0) preferences.defaultViewer() else mangaViewer
|
||||
viewer == ReaderActivity.WEBTOON
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
crop_borders.visibleIf { !isWebtoonViewer }
|
||||
crop_borders_webtoon.visibleIf { isWebtoonViewer }
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
subscriptions.unsubscribe()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,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)
|
||||
}
|
||||
|
||||
}
|
@ -16,6 +16,7 @@ import java.io.File
|
||||
* Class used to show BigPictureStyle notifications
|
||||
*/
|
||||
class SaveImageNotifier(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Notification builder.
|
||||
*/
|
||||
|
@ -0,0 +1,84 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.source.LocalSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Loader used to retrieve the [PageLoader] for a given chapter.
|
||||
*/
|
||||
class ChapterLoader(
|
||||
private val downloadManager: DownloadManager,
|
||||
private val manga: Manga,
|
||||
private val source: Source
|
||||
) {
|
||||
|
||||
/**
|
||||
* Returns a completable that assigns the page loader and loads the its pages. It just
|
||||
* completes if the chapter is already loaded.
|
||||
*/
|
||||
fun loadChapter(chapter: ReaderChapter): Completable {
|
||||
if (chapter.state is ReaderChapter.State.Loaded) {
|
||||
return Completable.complete()
|
||||
}
|
||||
|
||||
return Observable.just(chapter)
|
||||
.doOnNext { chapter.state = ReaderChapter.State.Loading }
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMap {
|
||||
Timber.d("Loading pages for ${chapter.chapter.name}")
|
||||
|
||||
val loader = getPageLoader(it)
|
||||
chapter.pageLoader = loader
|
||||
|
||||
loader.getPages().take(1).doOnNext { pages ->
|
||||
pages.forEach { it.chapter = chapter }
|
||||
}
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext { pages ->
|
||||
if (pages.isEmpty()) {
|
||||
throw Exception("Page list is empty")
|
||||
}
|
||||
|
||||
chapter.state = ReaderChapter.State.Loaded(pages)
|
||||
|
||||
// If the chapter is partially read, set the starting page to the last the user read
|
||||
// otherwise use the requested page.
|
||||
if (!chapter.chapter.read) {
|
||||
chapter.requestedPage = chapter.chapter.last_page_read
|
||||
}
|
||||
}
|
||||
.toCompletable()
|
||||
.doOnError { chapter.state = ReaderChapter.State.Error(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the page loader to use for this [chapter].
|
||||
*/
|
||||
private fun getPageLoader(chapter: ReaderChapter): PageLoader {
|
||||
val isDownloaded = downloadManager.isChapterDownloaded(chapter.chapter, manga, true)
|
||||
return when {
|
||||
isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager)
|
||||
source is HttpSource -> HttpPageLoader(chapter, source)
|
||||
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
|
||||
when (format) {
|
||||
is LocalSource.Format.Directory -> DirectoryPageLoader(format.file)
|
||||
is LocalSource.Format.Zip -> ZipPageLoader(format.file)
|
||||
is LocalSource.Format.Rar -> RarPageLoader(format.file)
|
||||
is LocalSource.Format.Epub -> EpubPageLoader(format.file)
|
||||
}
|
||||
}
|
||||
else -> error("Loader not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.util.ImageUtil
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import rx.Observable
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
|
||||
/**
|
||||
* Loader used to load a chapter from a directory given on [file].
|
||||
*/
|
||||
class DirectoryPageLoader(val file: File) : PageLoader() {
|
||||
|
||||
/**
|
||||
* Returns an observable containing the pages found on this directory ordered with a natural
|
||||
* comparator.
|
||||
*/
|
||||
override fun getPages(): Observable<List<ReaderPage>> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
|
||||
return file.listFiles()
|
||||
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
|
||||
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||
.mapIndexed { i, file ->
|
||||
val streamFn = { FileInputStream(file) }
|
||||
ReaderPage(i).apply {
|
||||
stream = streamFn
|
||||
status = Page.READY
|
||||
}
|
||||
}
|
||||
.let { Observable.just(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that emits a ready state.
|
||||
*/
|
||||
override fun getPage(page: ReaderPage): Observable<Int> {
|
||||
return Observable.just(Page.READY)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import android.app.Application
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* Loader used to load a chapter from the downloaded chapters.
|
||||
*/
|
||||
class DownloadPageLoader(
|
||||
private val chapter: ReaderChapter,
|
||||
private val manga: Manga,
|
||||
private val source: Source,
|
||||
private val downloadManager: DownloadManager
|
||||
) : PageLoader() {
|
||||
|
||||
/**
|
||||
* The application context. Needed to open input streams.
|
||||
*/
|
||||
private val context by injectLazy<Application>()
|
||||
|
||||
/**
|
||||
* Returns an observable containing the pages found on this downloaded chapter.
|
||||
*/
|
||||
override fun getPages(): Observable<List<ReaderPage>> {
|
||||
return downloadManager.buildPageList(source, manga, chapter.chapter)
|
||||
.map { pages ->
|
||||
pages.map { page ->
|
||||
ReaderPage(page.index, page.url, page.imageUrl, {
|
||||
context.contentResolver.openInputStream(page.uri)
|
||||
}).apply {
|
||||
status = Page.READY
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPage(page: ReaderPage): Observable<Int> {
|
||||
return Observable.just(Page.READY) // TODO maybe check if file still exists?
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.util.EpubFile
|
||||
import rx.Observable
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Loader used to load a chapter from a .epub file.
|
||||
*/
|
||||
class EpubPageLoader(file: File) : PageLoader() {
|
||||
|
||||
/**
|
||||
* The epub file.
|
||||
*/
|
||||
private val epub = EpubFile(file)
|
||||
|
||||
/**
|
||||
* Recycles this loader and the open zip.
|
||||
*/
|
||||
override fun recycle() {
|
||||
super.recycle()
|
||||
epub.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable containing the pages found on this zip archive ordered with a natural
|
||||
* comparator.
|
||||
*/
|
||||
override fun getPages(): Observable<List<ReaderPage>> {
|
||||
return epub.getImagesFromPages()
|
||||
.mapIndexed { i, path ->
|
||||
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) }
|
||||
ReaderPage(i).apply {
|
||||
stream = streamFn
|
||||
status = Page.READY
|
||||
}
|
||||
}
|
||||
.let { Observable.just(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that emits a ready state unless the loader was recycled.
|
||||
*/
|
||||
override fun getPage(page: ReaderPage): Observable<Int> {
|
||||
return Observable.just(if (isRecycled) {
|
||||
Page.ERROR
|
||||
} else {
|
||||
Page.READY
|
||||
})
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,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 }
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import android.support.annotation.CallSuper
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import rx.Observable
|
||||
|
||||
/**
|
||||
* A loader used to load pages into the reader. Any open resources must be cleaned up when the
|
||||
* method [recycle] is called.
|
||||
*/
|
||||
abstract class PageLoader {
|
||||
|
||||
/**
|
||||
* Whether this loader has been already recycled.
|
||||
*/
|
||||
var isRecycled = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Recycles this loader. Implementations must override this method to clean up any active
|
||||
* resources.
|
||||
*/
|
||||
@CallSuper
|
||||
open fun recycle() {
|
||||
isRecycled = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable containing the list of pages of a chapter. Only the first emission
|
||||
* will be used.
|
||||
*/
|
||||
abstract fun getPages(): Observable<List<ReaderPage>>
|
||||
|
||||
/**
|
||||
* Returns an observable that should inform of the progress of the page (see the Page class
|
||||
* for the available states)
|
||||
*/
|
||||
abstract fun getPage(page: ReaderPage): Observable<Int>
|
||||
|
||||
/**
|
||||
* Retries the given [page] in case it failed to load. This method only makes sense when an
|
||||
* online source is used.
|
||||
*/
|
||||
open fun retryPage(page: ReaderPage) {}
|
||||
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.util.ImageUtil
|
||||
import junrar.Archive
|
||||
import junrar.rarfile.FileHeader
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import rx.Observable
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.PipedInputStream
|
||||
import java.io.PipedOutputStream
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Loader used to load a chapter from a .rar or .cbr file.
|
||||
*/
|
||||
class RarPageLoader(file: File) : PageLoader() {
|
||||
|
||||
/**
|
||||
* The rar archive to load pages from.
|
||||
*/
|
||||
private val archive = Archive(file)
|
||||
|
||||
/**
|
||||
* Pool for copying compressed files to an input stream.
|
||||
*/
|
||||
private val pool = Executors.newFixedThreadPool(1)
|
||||
|
||||
/**
|
||||
* Recycles this loader and the open archive.
|
||||
*/
|
||||
override fun recycle() {
|
||||
super.recycle()
|
||||
archive.close()
|
||||
pool.shutdown()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable containing the pages found on this rar archive ordered with a natural
|
||||
* comparator.
|
||||
*/
|
||||
override fun getPages(): Observable<List<ReaderPage>> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
|
||||
return archive.fileHeaders
|
||||
.filter { !it.isDirectory && ImageUtil.isImage(it.fileNameString) { archive.getInputStream(it) } }
|
||||
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
|
||||
.mapIndexed { i, header ->
|
||||
val streamFn = { getStream(header) }
|
||||
|
||||
ReaderPage(i).apply {
|
||||
stream = streamFn
|
||||
status = Page.READY
|
||||
}
|
||||
}
|
||||
.let { Observable.just(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that emits a ready state unless the loader was recycled.
|
||||
*/
|
||||
override fun getPage(page: ReaderPage): Observable<Int> {
|
||||
return Observable.just(if (isRecycled) {
|
||||
Page.ERROR
|
||||
} else {
|
||||
Page.READY
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an input stream for the given [header].
|
||||
*/
|
||||
private fun getStream(header: FileHeader): InputStream {
|
||||
val pipeIn = PipedInputStream()
|
||||
val pipeOut = PipedOutputStream(pipeIn)
|
||||
pool.execute {
|
||||
try {
|
||||
pipeOut.use {
|
||||
archive.extractFile(header, it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
return pipeIn
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.util.ImageUtil
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import rx.Observable
|
||||
import java.io.File
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
/**
|
||||
* Loader used to load a chapter from a .zip or .cbz file.
|
||||
*/
|
||||
class ZipPageLoader(file: File) : PageLoader() {
|
||||
|
||||
/**
|
||||
* The zip file to load pages from.
|
||||
*/
|
||||
private val zip = ZipFile(file)
|
||||
|
||||
/**
|
||||
* Recycles this loader and the open zip.
|
||||
*/
|
||||
override fun recycle() {
|
||||
super.recycle()
|
||||
zip.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable containing the pages found on this zip archive ordered with a natural
|
||||
* comparator.
|
||||
*/
|
||||
override fun getPages(): Observable<List<ReaderPage>> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
|
||||
return zip.entries().toList()
|
||||
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
|
||||
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||
.mapIndexed { i, entry ->
|
||||
val streamFn = { zip.getInputStream(entry) }
|
||||
ReaderPage(i).apply {
|
||||
stream = streamFn
|
||||
status = Page.READY
|
||||
}
|
||||
}
|
||||
.let { Observable.just(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that emits a ready state unless the loader was recycled.
|
||||
*/
|
||||
override fun getPage(page: ReaderPage): Observable<Int> {
|
||||
return Observable.just(if (isRecycled) {
|
||||
Page.ERROR
|
||||
} else {
|
||||
Page.READY
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.model
|
||||
|
||||
sealed class ChapterTransition {
|
||||
|
||||
abstract val from: ReaderChapter
|
||||
abstract val to: ReaderChapter?
|
||||
|
||||
class Prev(
|
||||
override val from: ReaderChapter, override val to: ReaderChapter?
|
||||
) : ChapterTransition()
|
||||
class Next(
|
||||
override val from: ReaderChapter, override val to: ReaderChapter?
|
||||
) : ChapterTransition()
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is ChapterTransition) return false
|
||||
if (from == other.from && to == other.to) return true
|
||||
if (from == other.to && to == other.from) return true
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = from.hashCode()
|
||||
result = 31 * result + (to?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "${javaClass.simpleName}(from=${from.chapter.url}, to=${to?.chapter?.url})"
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,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()
|
||||
}
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.model
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import java.io.InputStream
|
||||
|
||||
class ReaderPage(
|
||||
index: Int,
|
||||
url: String = "",
|
||||
imageUrl: String? = null,
|
||||
var stream: (() -> InputStream)? = null
|
||||
) : Page(index, url, imageUrl, null) {
|
||||
|
||||
lateinit var chapter: ReaderChapter
|
||||
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.model
|
||||
|
||||
data class ViewerChapters(
|
||||
val currChapter: ReaderChapter,
|
||||
val prevChapter: ReaderChapter?,
|
||||
val nextChapter: ReaderChapter?
|
||||
) {
|
||||
|
||||
fun ref() {
|
||||
currChapter.ref()
|
||||
prevChapter?.ref()
|
||||
nextChapter?.ref()
|
||||
}
|
||||
|
||||
fun unref() {
|
||||
currChapter.unref()
|
||||
prevChapter?.unref()
|
||||
nextChapter?.unref()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
|
||||
/**
|
||||
* Interface for implementing a viewer.
|
||||
*/
|
||||
interface BaseViewer {
|
||||
|
||||
/**
|
||||
* Returns the view this viewer uses.
|
||||
*/
|
||||
fun getView(): View
|
||||
|
||||
/**
|
||||
* Destroys this viewer. Called when leaving the reader or swapping viewers.
|
||||
*/
|
||||
fun destroy() {}
|
||||
|
||||
/**
|
||||
* Tells this viewer to set the given [chapters] as active.
|
||||
*/
|
||||
fun setChapters(chapters: ViewerChapters)
|
||||
|
||||
/**
|
||||
* Tells this viewer to move to the given [page].
|
||||
*/
|
||||
fun moveToPage(page: ReaderPage)
|
||||
|
||||
/**
|
||||
* Called from the containing activity when a key [event] is received. It should return true
|
||||
* if the event was handled, false otherwise.
|
||||
*/
|
||||
fun handleKeyEvent(event: KeyEvent): Boolean
|
||||
|
||||
/**
|
||||
* Called from the containing activity when a generic motion [event] is received. It should
|
||||
* return true if the event was handled, false otherwise.
|
||||
*/
|
||||
fun handleGenericMotionEvent(event: MotionEvent): Boolean
|
||||
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewConfiguration
|
||||
|
||||
/**
|
||||
* A custom gesture detector that also implements an on long tap confirmed, because the built-in
|
||||
* one conflicts with the quick scale feature.
|
||||
*/
|
||||
open class GestureDetectorWithLongTap(
|
||||
context: Context,
|
||||
listener: Listener
|
||||
) : GestureDetector(context, listener) {
|
||||
|
||||
private val handler = Handler()
|
||||
private val slop = ViewConfiguration.get(context).scaledTouchSlop
|
||||
private val longTapTime = ViewConfiguration.getLongPressTimeout().toLong()
|
||||
private val doubleTapTime = ViewConfiguration.getDoubleTapTimeout().toLong()
|
||||
|
||||
private var downX = 0f
|
||||
private var downY = 0f
|
||||
private var lastUp = 0L
|
||||
private var lastDownEvent: MotionEvent? = null
|
||||
|
||||
/**
|
||||
* Runnable to execute when a long tap is confirmed.
|
||||
*/
|
||||
private val longTapFn = Runnable { listener.onLongTapConfirmed(lastDownEvent!!) }
|
||||
|
||||
override fun onTouchEvent(ev: MotionEvent): Boolean {
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
lastDownEvent?.recycle()
|
||||
lastDownEvent = MotionEvent.obtain(ev)
|
||||
|
||||
// This is the key difference with the built-in detector. We have to ignore the
|
||||
// event if the last up and current down are too close in time (double tap).
|
||||
if (ev.downTime - lastUp > doubleTapTime) {
|
||||
downX = ev.rawX
|
||||
downY = ev.rawY
|
||||
handler.postDelayed(longTapFn, longTapTime)
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (Math.abs(ev.rawX - downX) > slop || Math.abs(ev.rawY - downY) > slop) {
|
||||
handler.removeCallbacks(longTapFn)
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
lastUp = ev.eventTime
|
||||
handler.removeCallbacks(longTapFn)
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
handler.removeCallbacks(longTapFn)
|
||||
}
|
||||
}
|
||||
return super.onTouchEvent(ev)
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom listener to also include a long tap confirmed
|
||||
*/
|
||||
open class Listener : SimpleOnGestureListener() {
|
||||
/**
|
||||
* Notified when a long tap occurs with the initial on down [ev] that triggered it.
|
||||
*/
|
||||
open fun onLongTapConfirmed(ev: MotionEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.base
|
||||
|
||||
import android.net.Uri
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.view.View
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import kotlinx.android.synthetic.main.reader_page_decode_error.view.*
|
||||
|
||||
class PageDecodeErrorLayout(
|
||||
val view: View,
|
||||
val page: Page,
|
||||
val theme: Int,
|
||||
val retryListener: () -> Unit
|
||||
) {
|
||||
|
||||
init {
|
||||
val textColor = if (theme == ReaderActivity.BLACK_THEME)
|
||||
ContextCompat.getColor(view.context, R.color.textColorSecondaryDark)
|
||||
else
|
||||
ContextCompat.getColor(view.context, R.color.textColorSecondaryLight)
|
||||
|
||||
view.decode_error_text.setTextColor(textColor)
|
||||
|
||||
view.decode_retry.setOnClickListener {
|
||||
retryListener()
|
||||
}
|
||||
|
||||
view.decode_open_browser.setOnClickListener {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, Uri.parse(page.imageUrl))
|
||||
view.context.startActivity(intent)
|
||||
}
|
||||
|
||||
if (page.imageUrl == null) {
|
||||
view.decode_open_browser.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
interface OnChapterBoundariesOutListener {
|
||||
fun onFirstPageOutEvent()
|
||||
fun onLastPageOutEvent()
|
||||
}
|
@ -1,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)
|
||||
}
|
||||
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager;
|
||||
|
||||
import android.support.v4.view.PagerAdapter;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import rx.functions.Action1;
|
||||
|
||||
public interface Pager {
|
||||
|
||||
void setId(int id);
|
||||
void setLayoutParams(ViewGroup.LayoutParams layoutParams);
|
||||
|
||||
void setOffscreenPageLimit(int limit);
|
||||
|
||||
int getCurrentItem();
|
||||
void setCurrentItem(int item, boolean smoothScroll);
|
||||
|
||||
int getWidth();
|
||||
int getHeight();
|
||||
|
||||
PagerAdapter getAdapter();
|
||||
void setAdapter(PagerAdapter adapter);
|
||||
|
||||
void setOnChapterBoundariesOutListener(OnChapterBoundariesOutListener listener);
|
||||
|
||||
void setOnPageChangeListener(Action1<Integer> onPageChanged);
|
||||
void clearOnPageChangeListeners();
|
||||
}
|
@ -0,0 +1,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
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.support.v7.widget.AppCompatButton
|
||||
import android.view.MotionEvent
|
||||
|
||||
/**
|
||||
* A button class to be used by child views of the pager viewer. All tap gestures are handled by
|
||||
* the pager, but this class disables that behavior to allow clickable buttons.
|
||||
*/
|
||||
@SuppressLint("ViewConstructor")
|
||||
class PagerButton(context: Context, viewer: PagerViewer) : AppCompatButton(context) {
|
||||
|
||||
init {
|
||||
setOnTouchListener { _, event ->
|
||||
viewer.pager.setGestureDetectorEnabled(false)
|
||||
if (event.actionMasked == MotionEvent.ACTION_UP) {
|
||||
viewer.pager.setGestureDetectorEnabled(true)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,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
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -1,326 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.base.BaseReader
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.LeftToRightReader
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.RightToLeftReader
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
|
||||
/**
|
||||
* Implementation of a reader based on a ViewPager.
|
||||
*/
|
||||
abstract class PagerReader : BaseReader() {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Zoom automatic alignment.
|
||||
*/
|
||||
const val ALIGN_AUTO = 1
|
||||
|
||||
/**
|
||||
* Align to left.
|
||||
*/
|
||||
const val ALIGN_LEFT = 2
|
||||
|
||||
/**
|
||||
* Align to right.
|
||||
*/
|
||||
const val ALIGN_RIGHT = 3
|
||||
|
||||
/**
|
||||
* Align to right.
|
||||
*/
|
||||
const val ALIGN_CENTER = 4
|
||||
|
||||
/**
|
||||
* Left side region of the screen. Used for touch events.
|
||||
*/
|
||||
const val LEFT_REGION = 0.33f
|
||||
|
||||
/**
|
||||
* Right side region of the screen. Used for touch events.
|
||||
*/
|
||||
const val RIGHT_REGION = 0.66f
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic interface of a ViewPager.
|
||||
*/
|
||||
lateinit var pager: Pager
|
||||
private set
|
||||
|
||||
/**
|
||||
* Adapter of the pager.
|
||||
*/
|
||||
lateinit var adapter: PagerReaderAdapter
|
||||
private set
|
||||
|
||||
/**
|
||||
* Gesture detector for touch events.
|
||||
*/
|
||||
val gestureDetector by lazy { GestureDetector(context, ImageGestureListener()) }
|
||||
|
||||
/**
|
||||
* Subscriptions for reader settings.
|
||||
*/
|
||||
var subscriptions: CompositeSubscription? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether transitions are enabled or not.
|
||||
*/
|
||||
var transitions: Boolean = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether to crop image borders.
|
||||
*/
|
||||
var cropBorders: Boolean = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Duration of the double tap animation
|
||||
*/
|
||||
var doubleTapAnimDuration = 500
|
||||
private set
|
||||
|
||||
/**
|
||||
* Scale type (fit width, fit screen, etc).
|
||||
*/
|
||||
var scaleType = 1
|
||||
private set
|
||||
|
||||
/**
|
||||
* Zoom type (start position).
|
||||
*/
|
||||
var zoomType = 1
|
||||
private set
|
||||
|
||||
/**
|
||||
* Text color for black theme.
|
||||
*/
|
||||
val whiteColor by lazy { ContextCompat.getColor(context!!, R.color.textColorSecondaryDark) }
|
||||
|
||||
/**
|
||||
* Text color for white theme.
|
||||
*/
|
||||
val blackColor by lazy { ContextCompat.getColor(context!!, R.color.textColorSecondaryLight) }
|
||||
|
||||
/**
|
||||
* Initializes the pager.
|
||||
*
|
||||
* @param pager the pager to initialize.
|
||||
*/
|
||||
protected fun initializePager(pager: Pager) {
|
||||
adapter = PagerReaderAdapter(this)
|
||||
|
||||
this.pager = pager.apply {
|
||||
setLayoutParams(ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT))
|
||||
setOffscreenPageLimit(1)
|
||||
setId(R.id.reader_pager)
|
||||
setOnChapterBoundariesOutListener(object : OnChapterBoundariesOutListener {
|
||||
override fun onFirstPageOutEvent() {
|
||||
readerActivity.requestPreviousChapter()
|
||||
}
|
||||
|
||||
override fun onLastPageOutEvent() {
|
||||
readerActivity.requestNextChapter()
|
||||
}
|
||||
})
|
||||
setOnPageChangeListener { onPageChanged(it) }
|
||||
}
|
||||
pager.adapter = adapter
|
||||
|
||||
subscriptions = CompositeSubscription().apply {
|
||||
val preferences = readerActivity.preferences
|
||||
|
||||
add(preferences.imageDecoder()
|
||||
.asObservable()
|
||||
.doOnNext { setDecoderClass(it) }
|
||||
.skip(1)
|
||||
.distinctUntilChanged()
|
||||
.subscribe { refreshAdapter() })
|
||||
|
||||
add(preferences.zoomStart()
|
||||
.asObservable()
|
||||
.doOnNext { setZoomStart(it) }
|
||||
.skip(1)
|
||||
.distinctUntilChanged()
|
||||
.subscribe { refreshAdapter() })
|
||||
|
||||
add(preferences.imageScaleType()
|
||||
.asObservable()
|
||||
.doOnNext { scaleType = it }
|
||||
.skip(1)
|
||||
.distinctUntilChanged()
|
||||
.subscribe { refreshAdapter() })
|
||||
|
||||
add(preferences.pageTransitions()
|
||||
.asObservable()
|
||||
.subscribe { transitions = it })
|
||||
|
||||
add(preferences.cropBorders()
|
||||
.asObservable()
|
||||
.doOnNext { cropBorders = it }
|
||||
.skip(1)
|
||||
.distinctUntilChanged()
|
||||
.subscribe { refreshAdapter() })
|
||||
|
||||
add(preferences.doubleTapAnimSpeed()
|
||||
.asObservable()
|
||||
.subscribe { doubleTapAnimDuration = it })
|
||||
}
|
||||
|
||||
setPagesOnAdapter()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
pager.clearOnPageChangeListeners()
|
||||
subscriptions?.unsubscribe()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gesture detector for Subsampling Scale Image View.
|
||||
*/
|
||||
inner class ImageGestureListener : GestureDetector.SimpleOnGestureListener() {
|
||||
|
||||
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
||||
if (isAdded) {
|
||||
val positionX = e.x
|
||||
|
||||
if (positionX < pager.width * LEFT_REGION) {
|
||||
if (tappingEnabled) moveLeft()
|
||||
} else if (positionX > pager.width * RIGHT_REGION) {
|
||||
if (tappingEnabled) moveRight()
|
||||
} else {
|
||||
readerActivity.toggleMenu()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a new chapter is set in [BaseReader].
|
||||
*
|
||||
* @param chapter the chapter set.
|
||||
* @param currentPage the initial page to display.
|
||||
*/
|
||||
override fun onChapterSet(chapter: ReaderChapter, currentPage: Page) {
|
||||
this.currentPage = getPageIndex(currentPage) // we might have a new page object
|
||||
|
||||
// Make sure the view is already initialized.
|
||||
if (view != null) {
|
||||
setPagesOnAdapter()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a chapter is appended in [BaseReader].
|
||||
*
|
||||
* @param chapter the chapter appended.
|
||||
*/
|
||||
override fun onChapterAppended(chapter: ReaderChapter) {
|
||||
// Make sure the view is already initialized.
|
||||
if (view != null) {
|
||||
adapter.pages = pages
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the pages on the adapter.
|
||||
*/
|
||||
protected fun setPagesOnAdapter() {
|
||||
if (pages.isNotEmpty()) {
|
||||
// Prevent a wrong active page when changing chapters with the navigation buttons.
|
||||
val currPage = currentPage
|
||||
adapter.pages = pages
|
||||
currentPage = currPage
|
||||
if (currentPage == pager.currentItem) {
|
||||
onPageChanged(currentPage)
|
||||
} else {
|
||||
setActivePage(currentPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active page.
|
||||
*
|
||||
* @param pageNumber the index of the page from [pages].
|
||||
*/
|
||||
override fun setActivePage(pageNumber: Int) {
|
||||
pager.setCurrentItem(pageNumber, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the adapter.
|
||||
*/
|
||||
private fun refreshAdapter() {
|
||||
pager.adapter = adapter
|
||||
pager.setCurrentItem(currentPage, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a page to the right.
|
||||
*/
|
||||
override fun moveRight() {
|
||||
moveToNext()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a page to the left.
|
||||
*/
|
||||
override fun moveLeft() {
|
||||
moveToPrevious()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the next page or requests the next chapter if it's the last one.
|
||||
*/
|
||||
protected fun moveToNext() {
|
||||
if (pager.currentItem != pager.adapter.count - 1) {
|
||||
pager.setCurrentItem(pager.currentItem + 1, transitions)
|
||||
} else {
|
||||
readerActivity.requestNextChapter()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the previous page or requests the previous chapter if it's the first one.
|
||||
*/
|
||||
protected fun moveToPrevious() {
|
||||
if (pager.currentItem != 0) {
|
||||
pager.setCurrentItem(pager.currentItem - 1, transitions)
|
||||
} else {
|
||||
readerActivity.requestPreviousChapter()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the zoom start position.
|
||||
*
|
||||
* @param zoomStart the value stored in preferences.
|
||||
*/
|
||||
private fun setZoomStart(zoomStart: Int) {
|
||||
if (zoomStart == ALIGN_AUTO) {
|
||||
if (this is LeftToRightReader)
|
||||
setZoomStart(ALIGN_LEFT)
|
||||
else if (this is RightToLeftReader)
|
||||
setZoomStart(ALIGN_RIGHT)
|
||||
else
|
||||
setZoomStart(ALIGN_CENTER)
|
||||
} else {
|
||||
zoomType = zoomStart
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.support.v4.view.PagerAdapter
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
|
||||
|
||||
/**
|
||||
* Adapter of pages for a ViewPager.
|
||||
*/
|
||||
class PagerReaderAdapter(private val reader: PagerReader) : ViewPagerAdapter() {
|
||||
|
||||
/**
|
||||
* Pages stored in the adapter.
|
||||
*/
|
||||
var pages: List<Page> = emptyList()
|
||||
set(value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun createView(container: ViewGroup, position: Int): View {
|
||||
val view = container.inflate(R.layout.reader_pager_item) as PageView
|
||||
view.initialize(reader, pages[position])
|
||||
return view
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of pages.
|
||||
*/
|
||||
override fun getCount(): Int {
|
||||
return pages.size
|
||||
}
|
||||
|
||||
override fun getItemPosition(obj: Any): Int {
|
||||
val view = obj as PageView
|
||||
return if (view.page in pages) {
|
||||
PagerAdapter.POSITION_UNCHANGED
|
||||
} else {
|
||||
PagerAdapter.POSITION_NONE
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,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)
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,101 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.support.v4.view.PagerAdapter
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Pager adapter used by this [viewer] to where [ViewerChapters] updates are posted.
|
||||
*/
|
||||
class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
|
||||
|
||||
/**
|
||||
* List of currently set items.
|
||||
*/
|
||||
var items: List<Any> = emptyList()
|
||||
private set
|
||||
|
||||
/**
|
||||
* Updates this adapter with the given [chapters]. It handles setting a few pages of the
|
||||
* next/previous chapter to allow seamless transitions and inverting the pages if the viewer
|
||||
* has R2L direction.
|
||||
*/
|
||||
fun setChapters(chapters: ViewerChapters) {
|
||||
val newItems = mutableListOf<Any>()
|
||||
|
||||
// Add previous chapter pages and transition.
|
||||
if (chapters.prevChapter != null) {
|
||||
// We only need to add the last few pages of the previous chapter, because it'll be
|
||||
// selected as the current chapter when one of those pages is selected.
|
||||
val prevPages = chapters.prevChapter.pages
|
||||
if (prevPages != null) {
|
||||
newItems.addAll(prevPages.takeLast(2))
|
||||
}
|
||||
}
|
||||
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
|
||||
|
||||
// Add current chapter.
|
||||
val currPages = chapters.currChapter.pages
|
||||
if (currPages != null) {
|
||||
newItems.addAll(currPages)
|
||||
}
|
||||
|
||||
// Add next chapter transition and pages.
|
||||
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
|
||||
if (chapters.nextChapter != null) {
|
||||
// Add at most two pages, because this chapter will be selected before the user can
|
||||
// swap more pages.
|
||||
val nextPages = chapters.nextChapter.pages
|
||||
if (nextPages != null) {
|
||||
newItems.addAll(nextPages.take(2))
|
||||
}
|
||||
}
|
||||
|
||||
if (viewer is R2LPagerViewer) {
|
||||
newItems.reverse()
|
||||
}
|
||||
|
||||
items = newItems
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount of items of the adapter.
|
||||
*/
|
||||
override fun getCount(): Int {
|
||||
return items.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new view for the item at the given [position].
|
||||
*/
|
||||
override fun createView(container: ViewGroup, position: Int): View {
|
||||
val item = items[position]
|
||||
return when (item) {
|
||||
is ReaderPage -> PagerPageHolder(viewer, item)
|
||||
is ChapterTransition -> PagerTransitionHolder(viewer, item)
|
||||
else -> throw NotImplementedError("Holder for ${item.javaClass} not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current position of the given [view] on the adapter.
|
||||
*/
|
||||
override fun getItemPosition(view: Any): Int {
|
||||
if (view is PositionableView) {
|
||||
val position = items.indexOf(view.item)
|
||||
if (position != -1) {
|
||||
return position
|
||||
} else {
|
||||
Timber.d("Position for ${view.item} not found")
|
||||
}
|
||||
}
|
||||
return PagerAdapter.POSITION_NONE
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
|
||||
/**
|
||||
* Implementation of a left to right PagerViewer.
|
||||
*/
|
||||
class L2RPagerViewer(activity: ReaderActivity) : PagerViewer(activity) {
|
||||
/**
|
||||
* Creates a new left to right pager.
|
||||
*/
|
||||
override fun createPager(): Pager {
|
||||
return Pager(activity)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of a right to left PagerViewer.
|
||||
*/
|
||||
class R2LPagerViewer(activity: ReaderActivity) : PagerViewer(activity) {
|
||||
/**
|
||||
* Creates a new right to left pager.
|
||||
*/
|
||||
override fun createPager(): Pager {
|
||||
return Pager(activity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the next page. On a R2L pager the next page is the one at the left.
|
||||
*/
|
||||
override fun moveToNext() {
|
||||
moveLeft()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the previous page. On a R2L pager the previous page is the one at the right.
|
||||
*/
|
||||
override fun moveToPrevious() {
|
||||
moveRight()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of a vertical (top to bottom) PagerViewer.
|
||||
*/
|
||||
class VerticalPagerViewer(activity: ReaderActivity) : PagerViewer(activity) {
|
||||
/**
|
||||
* Creates a new vertical pager.
|
||||
*/
|
||||
override fun createPager(): Pager {
|
||||
return Pager(activity, isHorizontal = false)
|
||||
}
|
||||
}
|
@ -1,86 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal
|
||||
|
||||
import android.content.Context
|
||||
import android.support.v4.view.ViewPager
|
||||
import android.view.MotionEvent
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.OnChapterBoundariesOutListener
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.Pager
|
||||
import rx.functions.Action1
|
||||
|
||||
/**
|
||||
* Implementation of a [ViewPager] to add custom behavior on touch events.
|
||||
*/
|
||||
class HorizontalPager(context: Context) : ViewPager(context), Pager {
|
||||
|
||||
companion object {
|
||||
|
||||
const val SWIPE_TOLERANCE = 0.25f
|
||||
}
|
||||
|
||||
private var onChapterBoundariesOutListener: OnChapterBoundariesOutListener? = null
|
||||
|
||||
private var startDragX: Float = 0f
|
||||
|
||||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
||||
try {
|
||||
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_DOWN) {
|
||||
if (currentItem == 0 || currentItem == adapter!!.count - 1) {
|
||||
startDragX = ev.x
|
||||
}
|
||||
}
|
||||
|
||||
return super.onInterceptTouchEvent(ev)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onTouchEvent(ev: MotionEvent): Boolean {
|
||||
try {
|
||||
onChapterBoundariesOutListener?.let { listener ->
|
||||
if (currentItem == 0) {
|
||||
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
|
||||
val displacement = ev.x - startDragX
|
||||
|
||||
if (ev.x > startDragX && displacement > width * SWIPE_TOLERANCE) {
|
||||
listener.onFirstPageOutEvent()
|
||||
return true
|
||||
}
|
||||
|
||||
startDragX = 0f
|
||||
}
|
||||
} else if (currentItem == adapter!!.count - 1) {
|
||||
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
|
||||
val displacement = startDragX - ev.x
|
||||
|
||||
if (ev.x < startDragX && displacement > width * SWIPE_TOLERANCE) {
|
||||
listener.onLastPageOutEvent()
|
||||
return true
|
||||
}
|
||||
|
||||
startDragX = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.onTouchEvent(ev)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun setOnChapterBoundariesOutListener(listener: OnChapterBoundariesOutListener) {
|
||||
onChapterBoundariesOutListener = listener
|
||||
}
|
||||
|
||||
override fun setOnPageChangeListener(func: Action1<Int>) {
|
||||
addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
func.call(position)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerReader
|
||||
|
||||
/**
|
||||
* Left to Right reader.
|
||||
*/
|
||||
class LeftToRightReader : PagerReader() {
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
|
||||
return HorizontalPager(activity!!).apply { initializePager(this) }
|
||||
}
|
||||
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerReader
|
||||
|
||||
/**
|
||||
* Right to Left reader.
|
||||
*/
|
||||
class RightToLeftReader : PagerReader() {
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
|
||||
return HorizontalPager(activity!!).apply {
|
||||
rotation = 180f
|
||||
initializePager(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a page to the right.
|
||||
*/
|
||||
override fun moveRight() {
|
||||
moveToPrevious()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a page to the left.
|
||||
*/
|
||||
override fun moveLeft() {
|
||||
moveToNext()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a page down.
|
||||
*/
|
||||
override fun moveDown() {
|
||||
moveToNext()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a page up.
|
||||
*/
|
||||
override fun moveUp() {
|
||||
moveToPrevious()
|
||||
}
|
||||
|
||||
}
|
@ -1,84 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager.vertical
|
||||
|
||||
import android.content.Context
|
||||
import android.view.MotionEvent
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.OnChapterBoundariesOutListener
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.Pager
|
||||
import rx.functions.Action1
|
||||
|
||||
/**
|
||||
* Implementation of a [VerticalViewPagerImpl] to add custom behavior on touch events.
|
||||
*/
|
||||
class VerticalPager(context: Context) : VerticalViewPagerImpl(context), Pager {
|
||||
|
||||
private var onChapterBoundariesOutListener: OnChapterBoundariesOutListener? = null
|
||||
private var startDragY: Float = 0.toFloat()
|
||||
|
||||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
||||
try {
|
||||
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_DOWN) {
|
||||
if (currentItem == 0 || currentItem == adapter.count - 1) {
|
||||
startDragY = ev.y
|
||||
}
|
||||
}
|
||||
|
||||
return super.onInterceptTouchEvent(ev)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onTouchEvent(ev: MotionEvent): Boolean {
|
||||
try {
|
||||
onChapterBoundariesOutListener?.let { listener ->
|
||||
if (currentItem == 0) {
|
||||
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
|
||||
val displacement = ev.y - startDragY
|
||||
|
||||
if (ev.y > startDragY && displacement > height * SWIPE_TOLERANCE) {
|
||||
listener.onFirstPageOutEvent()
|
||||
return true
|
||||
}
|
||||
|
||||
startDragY = 0f
|
||||
}
|
||||
} else if (currentItem == adapter.count - 1) {
|
||||
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
|
||||
val displacement = startDragY - ev.y
|
||||
|
||||
if (ev.y < startDragY && displacement > height * SWIPE_TOLERANCE) {
|
||||
listener.onLastPageOutEvent()
|
||||
return true
|
||||
}
|
||||
|
||||
startDragY = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.onTouchEvent(ev)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun setOnChapterBoundariesOutListener(listener: OnChapterBoundariesOutListener) {
|
||||
onChapterBoundariesOutListener = listener
|
||||
}
|
||||
|
||||
override fun setOnPageChangeListener(func: Action1<Int>) {
|
||||
addOnPageChangeListener(object : VerticalViewPagerImpl.SimpleOnPageChangeListener() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
func.call(position)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val SWIPE_TOLERANCE = 0.25f
|
||||
}
|
||||
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.pager.vertical
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerReader
|
||||
|
||||
/**
|
||||
* Vertical reader.
|
||||
*/
|
||||
class VerticalReader : PagerReader() {
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
|
||||
return VerticalPager(activity!!).apply { initializePager(this) }
|
||||
}
|
||||
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -1,78 +1,172 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
|
||||
|
||||
import android.support.v7.util.DiffUtil
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
|
||||
/**
|
||||
* Adapter of pages for a RecyclerView.
|
||||
*
|
||||
* @param fragment the fragment containing this adapter.
|
||||
* RecyclerView Adapter used by this [viewer] to where [ViewerChapters] updates are posted.
|
||||
*/
|
||||
class WebtoonAdapter(val fragment: WebtoonReader) : RecyclerView.Adapter<WebtoonHolder>() {
|
||||
class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
/**
|
||||
* Pages stored in the adapter.
|
||||
* List of currently set items.
|
||||
*/
|
||||
var pages: List<Page>? = null
|
||||
var items: List<Any> = emptyList()
|
||||
private set
|
||||
|
||||
/**
|
||||
* Touch listener for images in holders.
|
||||
* Updates this adapter with the given [chapters]. It handles setting a few pages of the
|
||||
* next/previous chapter to allow seamless transitions.
|
||||
*/
|
||||
val touchListener = View.OnTouchListener { _, ev -> fragment.imageGestureDetector.onTouchEvent(ev) }
|
||||
fun setChapters(chapters: ViewerChapters) {
|
||||
val newItems = mutableListOf<Any>()
|
||||
|
||||
// Add previous chapter pages and transition.
|
||||
if (chapters.prevChapter != null) {
|
||||
// We only need to add the last few pages of the previous chapter, because it'll be
|
||||
// selected as the current chapter when one of those pages is selected.
|
||||
val prevPages = chapters.prevChapter.pages
|
||||
if (prevPages != null) {
|
||||
newItems.addAll(prevPages.takeLast(2))
|
||||
}
|
||||
}
|
||||
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
|
||||
|
||||
// Add current chapter.
|
||||
val currPages = chapters.currChapter.pages
|
||||
if (currPages != null) {
|
||||
newItems.addAll(currPages)
|
||||
}
|
||||
|
||||
// Add next chapter transition and pages.
|
||||
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
|
||||
if (chapters.nextChapter != null) {
|
||||
// Add at most two pages, because this chapter will be selected before the user can
|
||||
// swap more pages.
|
||||
val nextPages = chapters.nextChapter.pages
|
||||
if (nextPages != null) {
|
||||
newItems.addAll(nextPages.take(2))
|
||||
}
|
||||
}
|
||||
|
||||
val result = DiffUtil.calculateDiff(Callback(items, newItems))
|
||||
items = newItems
|
||||
result.dispatchUpdatesTo(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of pages.
|
||||
*
|
||||
* @return the number of pages or 0 if the list is null.
|
||||
* Returns the amount of items of the adapter.
|
||||
*/
|
||||
override fun getItemCount(): Int {
|
||||
return pages?.size ?: 0
|
||||
return items.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page given the position.
|
||||
*
|
||||
* @param position the position of the page.
|
||||
* @return the page.
|
||||
* Returns the view type for the item at the given [position].
|
||||
*/
|
||||
fun getItem(position: Int): Page {
|
||||
return pages!![position]
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
val item = items[position]
|
||||
return when (item) {
|
||||
is ReaderPage -> PAGE_VIEW
|
||||
is ChapterTransition -> TRANSITION_VIEW
|
||||
else -> error("Unknown view type for ${item.javaClass}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new view holder.
|
||||
*
|
||||
* @param parent the parent view.
|
||||
* @param viewType the type of the holder.
|
||||
* @return a new view holder for a manga.
|
||||
* Creates a new view holder for an item with the given [viewType].
|
||||
*/
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WebtoonHolder {
|
||||
val v = parent.inflate(R.layout.reader_webtoon_item)
|
||||
return WebtoonHolder(v, this)
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return when (viewType) {
|
||||
PAGE_VIEW -> {
|
||||
val view = FrameLayout(parent.context)
|
||||
WebtoonPageHolder(view, viewer)
|
||||
}
|
||||
TRANSITION_VIEW -> {
|
||||
val view = LinearLayout(parent.context)
|
||||
WebtoonTransitionHolder(view, viewer)
|
||||
}
|
||||
else -> error("Unknown view type")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a holder with a new position.
|
||||
*
|
||||
* @param holder the holder to bind.
|
||||
* @param position the position to bind.
|
||||
* Binds an existing view [holder] with the item at the given [position].
|
||||
*/
|
||||
override fun onBindViewHolder(holder: WebtoonHolder, position: Int) {
|
||||
val page = getItem(position)
|
||||
holder.onSetValues(page)
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
when (holder) {
|
||||
is WebtoonPageHolder -> holder.bind(item as ReaderPage)
|
||||
is WebtoonTransitionHolder -> holder.bind(item as ChapterTransition)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recycles the view holder.
|
||||
*
|
||||
* @param holder the holder to recycle.
|
||||
* Recycles an existing view [holder] before adding it to the view pool.
|
||||
*/
|
||||
override fun onViewRecycled(holder: WebtoonHolder) {
|
||||
holder.onRecycle()
|
||||
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
|
||||
when (holder) {
|
||||
is WebtoonPageHolder -> holder.recycle()
|
||||
is WebtoonTransitionHolder -> holder.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff util callback used to dispatch delta updates instead of full dataset changes.
|
||||
*/
|
||||
private class Callback(
|
||||
private val oldItems: List<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
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -165,9 +165,8 @@ class RecentChaptersPresenter(
|
||||
* @param chapters list of chapters
|
||||
*/
|
||||
fun deleteChapters(chapters: List<RecentChapterItem>) {
|
||||
Observable.from(chapters)
|
||||
.doOnNext { deleteChapter(it) }
|
||||
.toList()
|
||||
Observable.just(chapters)
|
||||
.doOnNext { deleteChaptersInternal(it) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst({ view, _ ->
|
||||
@ -184,16 +183,23 @@ class RecentChaptersPresenter(
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete selected chapter
|
||||
* Delete selected chapters
|
||||
*
|
||||
* @param item chapter that is selected
|
||||
* @param items chapters selected
|
||||
*/
|
||||
private fun deleteChapter(item: RecentChapterItem) {
|
||||
val source = sourceManager.get(item.manga.source) ?: return
|
||||
downloadManager.queue.remove(item.chapter)
|
||||
downloadManager.deleteChapter(item.chapter, item.manga, source)
|
||||
item.status = Download.NOT_DOWNLOADED
|
||||
item.download = null
|
||||
private fun deleteChaptersInternal(chapterItems: List<RecentChapterItem>) {
|
||||
val itemsByManga = chapterItems.groupBy { it.manga.id }
|
||||
for ((_, items) in itemsByManga) {
|
||||
val manga = items.first().manga
|
||||
val source = sourceManager.get(manga.source) ?: continue
|
||||
val chapters = items.map { it.chapter }
|
||||
|
||||
downloadManager.deleteChapters(chapters, manga, source)
|
||||
items.forEach {
|
||||
it.status = Download.NOT_DOWNLOADED
|
||||
it.download = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -54,14 +54,6 @@ class SettingsReaderController : SettingsController() {
|
||||
defaultValue = "0"
|
||||
summary = "%s"
|
||||
}
|
||||
intListPreference {
|
||||
key = Keys.imageDecoder
|
||||
titleRes = R.string.pref_image_decoder
|
||||
entries = arrayOf("Image", "Rapid", "Skia")
|
||||
entryValues = arrayOf("0", "1", "2")
|
||||
defaultValue = "0"
|
||||
summary = "%s"
|
||||
}
|
||||
intListPreference {
|
||||
key = Keys.doubleTapAnimationSpeed
|
||||
titleRes = R.string.pref_double_tap_anim_speed
|
||||
|
@ -11,6 +11,7 @@ import android.content.pm.PackageManager
|
||||
import android.content.res.Resources
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.PowerManager
|
||||
import android.support.annotation.AttrRes
|
||||
import android.support.annotation.StringRes
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.content.ContextCompat
|
||||
@ -79,7 +80,7 @@ fun Context.hasPermission(permission: String)
|
||||
*
|
||||
* @param resource the attribute.
|
||||
*/
|
||||
fun Context.getResourceColor(@StringRes resource: Int): Int {
|
||||
fun Context.getResourceColor(@AttrRes resource: Int): Int {
|
||||
val typedArray = obtainStyledAttributes(intArrayOf(resource))
|
||||
val attrValue = typedArray.getColor(0, 0)
|
||||
typedArray.recycle()
|
||||
|
@ -8,47 +8,9 @@ import android.os.Environment
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.support.v4.os.EnvironmentCompat
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.net.URLConnection
|
||||
|
||||
object DiskUtil {
|
||||
|
||||
fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
|
||||
val contentType = try {
|
||||
URLConnection.guessContentTypeFromName(name)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} ?: openStream?.let { findImageMime(it) }
|
||||
|
||||
return contentType?.startsWith("image/") ?: false
|
||||
}
|
||||
|
||||
fun findImageMime(openStream: () -> InputStream): String? {
|
||||
try {
|
||||
openStream().buffered().use {
|
||||
val bytes = ByteArray(8)
|
||||
it.mark(bytes.size)
|
||||
val length = it.read(bytes, 0, bytes.size)
|
||||
it.reset()
|
||||
if (length == -1)
|
||||
return null
|
||||
if (bytes[0] == 'G'.toByte() && bytes[1] == 'I'.toByte() && bytes[2] == 'F'.toByte() && bytes[3] == '8'.toByte()) {
|
||||
return "image/gif"
|
||||
} else if (bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && bytes[2] == 0x4E.toByte()
|
||||
&& bytes[3] == 0x47.toByte() && bytes[4] == 0x0D.toByte() && bytes[5] == 0x0A.toByte()
|
||||
&& bytes[6] == 0x1A.toByte() && bytes[7] == 0x0A.toByte()) {
|
||||
return "image/png"
|
||||
} else if (bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte()) {
|
||||
return "image/jpeg"
|
||||
} else if (bytes[0] == 'W'.toByte() && bytes[1] == 'E'.toByte() && bytes[2] == 'B'.toByte() && bytes[3] == 'P'.toByte()) {
|
||||
return "image/webp"
|
||||
}
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun hashKeyForDisk(key: String): String {
|
||||
return Hash.md5(key)
|
||||
}
|
||||
|
117
app/src/main/java/eu/kanade/tachiyomi/util/EpubFile.kt
Normal file
117
app/src/main/java/eu/kanade/tachiyomi/util/EpubFile.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
78
app/src/main/java/eu/kanade/tachiyomi/util/ImageUtil.kt
Normal file
78
app/src/main/java/eu/kanade/tachiyomi/util/ImageUtil.kt
Normal 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")
|
||||
}
|
||||
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
@ -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> {
|
||||
return Observable.combineLatest(this, o2, combineFn)
|
||||
}
|
||||
|
||||
fun Subscription.addTo(subscriptions: CompositeSubscription) {
|
||||
subscriptions.add(this)
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
@ -27,4 +27,8 @@ abstract class ViewPagerAdapter : PagerAdapter() {
|
||||
return view === obj
|
||||
}
|
||||
|
||||
interface PositionableView {
|
||||
val item: Any
|
||||
}
|
||||
|
||||
}
|
9
app/src/main/res/drawable/ic_image_black_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_image_black_24dp.xml
Normal 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>
|
50
app/src/main/res/layout-land/reader_color_filter_sheet.xml
Normal file
50
app/src/main/res/layout-land/reader_color_filter_sheet.xml
Normal 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>
|
@ -11,17 +11,18 @@
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/reader"
|
||||
android:id="@+id/viewer_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
</FrameLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/please_wait"
|
||||
style="?android:attr/progressBarStyleLarge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"/>
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"/>
|
||||
|
||||
<eu.kanade.tachiyomi.ui.reader.PageIndicatorTextView
|
||||
android:id="@+id/page_number"
|
||||
@ -39,6 +40,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
android:theme="?attr/actionBarTheme"
|
||||
android:visibility="invisible"
|
||||
tools:visibility="visible">
|
||||
|
||||
@ -47,8 +49,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="?colorPrimary"
|
||||
android:elevation="4dp"
|
||||
android:theme="?attr/actionBarTheme"/>
|
||||
android:elevation="4dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/reader_menu_bottom"
|
||||
@ -58,7 +59,6 @@
|
||||
android:gravity="center"
|
||||
android:background="?colorPrimary"
|
||||
android:orientation="horizontal"
|
||||
android:focusable="false"
|
||||
android:descendantFocusability="blocksDescendants">
|
||||
|
||||
<ImageButton
|
||||
@ -66,35 +66,39 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="@dimen/material_layout_keylines_screen_edge_margin"
|
||||
android:background="?android:selectableItemBackground"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
app:srcCompat="@drawable/ic_skip_previous_white_24dp"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/left_page_text"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:textSize="15sp"/>
|
||||
android:textSize="15sp"
|
||||
android:clickable="true"
|
||||
tools:text="1"/>
|
||||
|
||||
<SeekBar
|
||||
<eu.kanade.tachiyomi.ui.reader.ReaderSeekBar
|
||||
android:id="@+id/page_seekbar"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/right_page_text"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:textSize="15sp"/>
|
||||
android:textSize="15sp"
|
||||
android:clickable="true"
|
||||
tools:text="15"/>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/right_chapter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="@dimen/material_layout_keylines_screen_edge_margin"
|
||||
android:background="?android:selectableItemBackground"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
app:srcCompat="@drawable/ic_skip_next_white_24dp"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
205
app/src/main/res/layout/reader_color_filter.xml
Normal file
205
app/src/main/res/layout/reader_color_filter.xml
Normal 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>
|
39
app/src/main/res/layout/reader_color_filter_sheet.xml
Normal file
39
app/src/main/res/layout/reader_color_filter_sheet.xml
Normal 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>
|
@ -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>
|
@ -1,32 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="512dp"
|
||||
android:layout_gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/decode_error_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/decode_image_error"
|
||||
android:layout_margin="8dp"
|
||||
android:gravity="center"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/decode_retry"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:text="@string/action_retry"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/decode_open_browser"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:text="@string/action_open_in_browser"/>
|
||||
|
||||
</LinearLayout>
|
93
app/src/main/res/layout/reader_page_sheet.xml
Normal file
93
app/src/main/res/layout/reader_page_sheet.xml
Normal 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>
|
@ -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>
|
@ -1,186 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:setting="http://schemas.android.com/tools"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/material_component_dialogs_padding_around_content_area"
|
||||
android:divider="@drawable/empty_divider"
|
||||
android:showDividers="middle">
|
||||
|
||||
<!-- Viewer for this series -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:text="@string/viewer_for_this_series" />
|
||||
|
||||
<android.support.v7.widget.AppCompatSpinner
|
||||
android:id="@+id/viewer"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="2"
|
||||
android:entries="@array/viewers_selector">
|
||||
|
||||
</android.support.v7.widget.AppCompatSpinner>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Rotation -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:text="@string/pref_rotation_type" />
|
||||
|
||||
<android.support.v7.widget.AppCompatSpinner
|
||||
android:id="@+id/rotation_mode"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="2"
|
||||
android:entries="@array/rotation_type">
|
||||
|
||||
</android.support.v7.widget.AppCompatSpinner>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Scale type -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:text="@string/pref_image_scale_type" />
|
||||
|
||||
<android.support.v7.widget.AppCompatSpinner
|
||||
android:id="@+id/scale_type"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="2"
|
||||
android:entries="@array/image_scale_type">
|
||||
|
||||
</android.support.v7.widget.AppCompatSpinner>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Zoom start position -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:text="@string/pref_zoom_start" />
|
||||
|
||||
<android.support.v7.widget.AppCompatSpinner
|
||||
android:id="@+id/zoom_start"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="2"
|
||||
android:entries="@array/zoom_start">
|
||||
|
||||
</android.support.v7.widget.AppCompatSpinner>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Image decoder -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:text="@string/pref_image_decoder" />
|
||||
|
||||
<android.support.v7.widget.AppCompatSpinner
|
||||
android:id="@+id/image_decoder"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="2"
|
||||
android:entries="@array/image_decoders">
|
||||
|
||||
</android.support.v7.widget.AppCompatSpinner>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Background color -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:text="@string/pref_reader_theme" />
|
||||
|
||||
<android.support.v7.widget.AppCompatSpinner
|
||||
android:id="@+id/background_color"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="2"
|
||||
android:entries="@array/reader_themes">
|
||||
|
||||
</android.support.v7.widget.AppCompatSpinner>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<android.support.v7.widget.SwitchCompat
|
||||
android:id="@+id/show_page_number"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/pref_show_page_number"/>
|
||||
|
||||
<android.support.v7.widget.SwitchCompat
|
||||
android:id="@+id/crop_borders"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/pref_crop_borders"/>
|
||||
|
||||
<android.support.v7.widget.SwitchCompat
|
||||
android:id="@+id/crop_borders_webtoon"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/pref_crop_borders"/>
|
||||
|
||||
<android.support.v7.widget.SwitchCompat
|
||||
android:id="@+id/fullscreen"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/pref_fullscreen"/>
|
||||
|
||||
</LinearLayout>
|
247
app/src/main/res/layout/reader_settings_sheet.xml
Normal file
247
app/src/main/res/layout/reader_settings_sheet.xml
Normal 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>
|
@ -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
Loading…
Reference in New Issue
Block a user