mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-11-17 11:09:19 +01:00
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 {
|
dependencies {
|
||||||
|
|
||||||
// Modified dependencies
|
// Modified dependencies
|
||||||
implementation 'com.github.inorichi:subsampling-scale-image-view:81b9d68'
|
implementation('com.github.inorichi:subsampling-scale-image-view:caad3e4')
|
||||||
implementation 'com.github.inorichi:junrar-android:634c1f5'
|
implementation 'com.github.inorichi:junrar-android:634c1f5'
|
||||||
|
|
||||||
// Android support library
|
// Android support library
|
||||||
@ -116,7 +116,7 @@ dependencies {
|
|||||||
implementation "com.android.support:support-annotations:$support_library_version"
|
implementation "com.android.support:support-annotations:$support_library_version"
|
||||||
implementation "com.android.support:customtabs:$support_library_version"
|
implementation "com.android.support:customtabs:$support_library_version"
|
||||||
|
|
||||||
implementation 'com.android.support.constraint:constraint-layout:1.1.0-beta6'
|
implementation 'com.android.support.constraint:constraint-layout:1.1.2'
|
||||||
|
|
||||||
implementation 'com.android.support:multidex:1.0.2'
|
implementation 'com.android.support:multidex:1.0.2'
|
||||||
|
|
||||||
@ -201,6 +201,8 @@ dependencies {
|
|||||||
implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4'
|
implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4'
|
||||||
implementation 'com.github.mthli:Slice:v1.2'
|
implementation 'com.github.mthli:Slice:v1.2'
|
||||||
implementation 'me.gujun.android.taggroup:library:1.4@aar'
|
implementation 'me.gujun.android.taggroup:library:1.4@aar'
|
||||||
|
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
|
||||||
|
implementation 'com.github.inorichi:DirectionalViewPager:3acc51a'
|
||||||
|
|
||||||
// Conductor
|
// Conductor
|
||||||
implementation "com.github.inorichi.Conductor:conductor:be8b3c5"
|
implementation "com.github.inorichi.Conductor:conductor:be8b3c5"
|
||||||
@ -235,7 +237,7 @@ dependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.2.30'
|
ext.kotlin_version = '1.2.60'
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
@ -32,8 +32,7 @@
|
|||||||
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/>
|
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/>
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.reader.ReaderActivity"
|
android:name=".ui.reader.ReaderActivity" />
|
||||||
android:theme="@style/Theme.Reader" />
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".widget.CustomLayoutPickerActivity"
|
android:name=".widget.CustomLayoutPickerActivity"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@ -66,16 +65,6 @@
|
|||||||
android:resource="@xml/provider_paths" />
|
android:resource="@xml/provider_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="eu.kanade.tachiyomi.util.ZipContentProvider"
|
|
||||||
android:authorities="${applicationId}.zip-provider"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="eu.kanade.tachiyomi.util.RarContentProvider"
|
|
||||||
android:authorities="${applicationId}.rar-provider"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".data.notification.NotificationReceiver"
|
android:name=".data.notification.NotificationReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
@ -23,10 +23,12 @@ import java.util.concurrent.TimeUnit
|
|||||||
* @param sourceManager the source manager.
|
* @param sourceManager the source manager.
|
||||||
* @param preferences the preferences of the app.
|
* @param preferences the preferences of the app.
|
||||||
*/
|
*/
|
||||||
class DownloadCache(private val context: Context,
|
class DownloadCache(
|
||||||
private val provider: DownloadProvider,
|
private val context: Context,
|
||||||
private val sourceManager: SourceManager = Injekt.get(),
|
private val provider: DownloadProvider,
|
||||||
private val preferences: PreferencesHelper = Injekt.get()) {
|
private val sourceManager: SourceManager,
|
||||||
|
private val preferences: PreferencesHelper = Injekt.get()
|
||||||
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The interval after which this cache should be invalidated. 1 hour shouldn't cause major
|
* The interval after which this cache should be invalidated. 1 hour shouldn't cause major
|
||||||
@ -194,6 +196,24 @@ class DownloadCache(private val context: Context,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a list of chapters that have been deleted from this cache.
|
||||||
|
*
|
||||||
|
* @param chapters the list of chapter to remove.
|
||||||
|
* @param manga the manga of the chapter.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun removeChapters(chapters: List<Chapter>, manga: Manga) {
|
||||||
|
val sourceDir = rootDir.files[manga.source] ?: return
|
||||||
|
val mangaDir = sourceDir.files[provider.getMangaDirName(manga)] ?: return
|
||||||
|
for (chapter in chapters) {
|
||||||
|
val chapterDirName = provider.getChapterDirName(chapter)
|
||||||
|
if (chapterDirName in mangaDir.files) {
|
||||||
|
mangaDir.files -= chapterDirName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a manga that has been deleted from this cache.
|
* Removes a manga that has been deleted from this cache.
|
||||||
*
|
*
|
||||||
|
@ -7,8 +7,10 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
|
|||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is used to manage chapter downloads in the application. It must be instantiated once
|
* This class is used to manage chapter downloads in the application. It must be instantiated once
|
||||||
@ -19,6 +21,11 @@ import rx.Observable
|
|||||||
*/
|
*/
|
||||||
class DownloadManager(context: Context) {
|
class DownloadManager(context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sources manager.
|
||||||
|
*/
|
||||||
|
private val sourceManager by injectLazy<SourceManager>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads provider, used to retrieve the folders where the chapters are or should be stored.
|
* Downloads provider, used to retrieve the folders where the chapters are or should be stored.
|
||||||
*/
|
*/
|
||||||
@ -27,12 +34,17 @@ class DownloadManager(context: Context) {
|
|||||||
/**
|
/**
|
||||||
* Cache of downloaded chapters.
|
* Cache of downloaded chapters.
|
||||||
*/
|
*/
|
||||||
private val cache = DownloadCache(context, provider)
|
private val cache = DownloadCache(context, provider, sourceManager)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloader whose only task is to download chapters.
|
* Downloader whose only task is to download chapters.
|
||||||
*/
|
*/
|
||||||
private val downloader = Downloader(context, provider, cache)
|
private val downloader = Downloader(context, provider, cache, sourceManager)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue to delay the deletion of a list of chapters until triggered.
|
||||||
|
*/
|
||||||
|
private val pendingDeleter = DownloadPendingDeleter(context)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads queue, where the pending chapters are stored.
|
* Downloads queue, where the pending chapters are stored.
|
||||||
@ -146,15 +158,20 @@ class DownloadManager(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes the directory of a downloaded chapter.
|
* Deletes the directories of a list of downloaded chapters.
|
||||||
*
|
*
|
||||||
* @param chapter the chapter to delete.
|
* @param chapters the list of chapters to delete.
|
||||||
* @param manga the manga of the chapter.
|
* @param manga the manga of the chapters.
|
||||||
* @param source the source of the chapter.
|
* @param source the source of the chapters.
|
||||||
*/
|
*/
|
||||||
fun deleteChapter(chapter: Chapter, manga: Manga, source: Source) {
|
fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source) {
|
||||||
provider.findChapterDir(chapter, manga, source)?.delete()
|
queue.remove(chapters)
|
||||||
cache.removeChapter(chapter, manga)
|
val chapterDirs = provider.findChapterDirs(chapters, manga, source)
|
||||||
|
chapterDirs.forEach { it.delete() }
|
||||||
|
cache.removeChapters(chapters, manga)
|
||||||
|
if (cache.getDownloadCount(manga) == 0) { // Delete manga directory if empty
|
||||||
|
chapterDirs.firstOrNull()?.parentFile?.delete()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -164,7 +181,30 @@ class DownloadManager(context: Context) {
|
|||||||
* @param source the source of the manga.
|
* @param source the source of the manga.
|
||||||
*/
|
*/
|
||||||
fun deleteManga(manga: Manga, source: Source) {
|
fun deleteManga(manga: Manga, source: Source) {
|
||||||
|
queue.remove(manga)
|
||||||
provider.findMangaDir(manga, source)?.delete()
|
provider.findMangaDir(manga, source)?.delete()
|
||||||
cache.removeManga(manga)
|
cache.removeManga(manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a list of chapters to be deleted later.
|
||||||
|
*
|
||||||
|
* @param chapters the list of chapters to delete.
|
||||||
|
* @param manga the manga of the chapters.
|
||||||
|
*/
|
||||||
|
fun enqueueDeleteChapters(chapters: List<Chapter>, manga: Manga) {
|
||||||
|
pendingDeleter.addChapters(chapters, manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers the execution of the deletion of pending chapters.
|
||||||
|
*/
|
||||||
|
fun deletePendingChapters() {
|
||||||
|
val pendingChapters = pendingDeleter.getPendingChapters()
|
||||||
|
for ((manga, chapters) in pendingChapters) {
|
||||||
|
val source = sourceManager.get(manga.source) ?: continue
|
||||||
|
deleteChapters(chapters, manga, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,180 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.download
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.github.salomonbrys.kotson.fromJson
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class used to keep a list of chapters for future deletion.
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
*/
|
||||||
|
class DownloadPendingDeleter(context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gson instance to encode and decode chapters.
|
||||||
|
*/
|
||||||
|
private val gson by injectLazy<Gson>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferences used to store the list of chapters to delete.
|
||||||
|
*/
|
||||||
|
private val prefs = context.getSharedPreferences("chapters_to_delete", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last added chapter, used to avoid decoding from the preference too often.
|
||||||
|
*/
|
||||||
|
private var lastAddedEntry: Entry? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a list of chapters for future deletion.
|
||||||
|
*
|
||||||
|
* @param chapters the chapters to be deleted.
|
||||||
|
* @param manga the manga of the chapters.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun addChapters(chapters: List<Chapter>, manga: Manga) {
|
||||||
|
val lastEntry = lastAddedEntry
|
||||||
|
|
||||||
|
val newEntry = if (lastEntry != null && lastEntry.manga.id == manga.id) {
|
||||||
|
// Append new chapters
|
||||||
|
val newChapters = lastEntry.chapters.addUniqueById(chapters)
|
||||||
|
|
||||||
|
// If no chapters were added, do nothing
|
||||||
|
if (newChapters.size == lastEntry.chapters.size) return
|
||||||
|
|
||||||
|
// Last entry matches the manga, reuse it to avoid decoding json from preferences
|
||||||
|
lastEntry.copy(chapters = newChapters)
|
||||||
|
} else {
|
||||||
|
val existingEntry = prefs.getString(manga.id!!.toString(), null)
|
||||||
|
if (existingEntry != null) {
|
||||||
|
// Existing entry found on preferences, decode json and add the new chapter
|
||||||
|
val savedEntry = gson.fromJson<Entry>(existingEntry)
|
||||||
|
|
||||||
|
// Append new chapters
|
||||||
|
val newChapters = savedEntry.chapters.addUniqueById(chapters)
|
||||||
|
|
||||||
|
// If no chapters were added, do nothing
|
||||||
|
if (newChapters.size == savedEntry.chapters.size) return
|
||||||
|
|
||||||
|
savedEntry.copy(chapters = newChapters)
|
||||||
|
} else {
|
||||||
|
// No entry has been found yet, create a new one
|
||||||
|
Entry(chapters.map { it.toEntry() }, manga.toEntry())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current state
|
||||||
|
val json = gson.toJson(newEntry)
|
||||||
|
prefs.edit().putString(newEntry.manga.id.toString(), json).apply()
|
||||||
|
lastAddedEntry = newEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of chapters to be deleted grouped by its manga.
|
||||||
|
*
|
||||||
|
* Note: the returned list of manga and chapters only contain basic information needed by the
|
||||||
|
* downloader, so don't use them for anything else.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun getPendingChapters(): Map<Manga, List<Chapter>> {
|
||||||
|
val entries = decodeAll()
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
lastAddedEntry = null
|
||||||
|
|
||||||
|
return entries.associate { entry ->
|
||||||
|
entry.manga.toModel() to entry.chapters.map { it.toModel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes all the chapters from preferences.
|
||||||
|
*/
|
||||||
|
private fun decodeAll(): List<Entry> {
|
||||||
|
return prefs.all.values.mapNotNull { rawEntry ->
|
||||||
|
try {
|
||||||
|
(rawEntry as? String)?.let { gson.fromJson<Entry>(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy of chapter entries ensuring no duplicates by chapter id.
|
||||||
|
*/
|
||||||
|
private fun List<ChapterEntry>.addUniqueById(chapters: List<Chapter>): List<ChapterEntry> {
|
||||||
|
val newList = toMutableList()
|
||||||
|
for (chapter in chapters) {
|
||||||
|
if (none { it.id == chapter.id }) {
|
||||||
|
newList.add(chapter.toEntry())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newList
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class used to save an entry of chapters with their manga into preferences.
|
||||||
|
*/
|
||||||
|
private data class Entry(
|
||||||
|
val chapters: List<ChapterEntry>,
|
||||||
|
val manga: MangaEntry
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class used to save an entry for a chapter into preferences.
|
||||||
|
*/
|
||||||
|
private data class ChapterEntry(
|
||||||
|
val id: Long,
|
||||||
|
val url: String,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class used to save an entry for a manga into preferences.
|
||||||
|
*/
|
||||||
|
private data class MangaEntry(
|
||||||
|
val id: Long,
|
||||||
|
val url: String,
|
||||||
|
val title: String,
|
||||||
|
val source: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a manga entry from a manga model.
|
||||||
|
*/
|
||||||
|
private fun Manga.toEntry(): MangaEntry {
|
||||||
|
return MangaEntry(id!!, url, title, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a chapter entry from a chapter model.
|
||||||
|
*/
|
||||||
|
private fun Chapter.toEntry(): ChapterEntry {
|
||||||
|
return ChapterEntry(id!!, url, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a manga model from a manga entry.
|
||||||
|
*/
|
||||||
|
private fun MangaEntry.toModel(): Manga {
|
||||||
|
return Manga.create(url, title, source).also {
|
||||||
|
it.id = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a chapter model from a chapter entry.
|
||||||
|
*/
|
||||||
|
private fun ChapterEntry.toModel(): Chapter {
|
||||||
|
return Chapter.create().also {
|
||||||
|
it.id = id
|
||||||
|
it.url = url
|
||||||
|
it.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -81,6 +81,18 @@ class DownloadProvider(private val context: Context) {
|
|||||||
return mangaDir?.findFile(getChapterDirName(chapter))
|
return mangaDir?.findFile(getChapterDirName(chapter))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of downloaded directories for the chapters that exist.
|
||||||
|
*
|
||||||
|
* @param chapters the chapters to query.
|
||||||
|
* @param manga the manga of the chapter.
|
||||||
|
* @param source the source of the chapter.
|
||||||
|
*/
|
||||||
|
fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): List<UniFile> {
|
||||||
|
val mangaDir = findMangaDir(manga, source) ?: return emptyList()
|
||||||
|
return chapters.mapNotNull { mangaDir.findFile(getChapterDirName(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the download directory name for a source.
|
* Returns the download directory name for a source.
|
||||||
*
|
*
|
||||||
@ -108,4 +120,4 @@ class DownloadProvider(private val context: Context) {
|
|||||||
return DiskUtil.buildValidFilename(chapter.name)
|
return DiskUtil.buildValidFilename(chapter.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,10 @@ import uy.kohesive.injekt.injectLazy
|
|||||||
*
|
*
|
||||||
* @param context the application context.
|
* @param context the application context.
|
||||||
*/
|
*/
|
||||||
class DownloadStore(context: Context) {
|
class DownloadStore(
|
||||||
|
context: Context,
|
||||||
|
private val sourceManager: SourceManager
|
||||||
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preference file where active downloads are stored.
|
* Preference file where active downloads are stored.
|
||||||
@ -26,11 +29,6 @@ class DownloadStore(context: Context) {
|
|||||||
*/
|
*/
|
||||||
private val gson: Gson by injectLazy()
|
private val gson: Gson by injectLazy()
|
||||||
|
|
||||||
/**
|
|
||||||
* Source manager.
|
|
||||||
*/
|
|
||||||
private val sourceManager: SourceManager by injectLazy()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database helper.
|
* Database helper.
|
||||||
*/
|
*/
|
||||||
@ -83,7 +81,7 @@ class DownloadStore(context: Context) {
|
|||||||
fun restore(): List<Download> {
|
fun restore(): List<Download> {
|
||||||
val objs = preferences.all
|
val objs = preferences.all
|
||||||
.mapNotNull { it.value as? String }
|
.mapNotNull { it.value as? String }
|
||||||
.map { deserialize(it) }
|
.mapNotNull { deserialize(it) }
|
||||||
.sortedBy { it.order }
|
.sortedBy { it.order }
|
||||||
|
|
||||||
val downloads = mutableListOf<Download>()
|
val downloads = mutableListOf<Download>()
|
||||||
@ -119,8 +117,12 @@ class DownloadStore(context: Context) {
|
|||||||
*
|
*
|
||||||
* @param string the download as string.
|
* @param string the download as string.
|
||||||
*/
|
*/
|
||||||
private fun deserialize(string: String): DownloadObject {
|
private fun deserialize(string: String): DownloadObject? {
|
||||||
return gson.fromJson(string, DownloadObject::class.java)
|
return try {
|
||||||
|
gson.fromJson(string, DownloadObject::class.java)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -132,4 +134,4 @@ class DownloadStore(context: Context) {
|
|||||||
*/
|
*/
|
||||||
data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int)
|
data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,6 @@ import rx.android.schedulers.AndroidSchedulers
|
|||||||
import rx.schedulers.Schedulers
|
import rx.schedulers.Schedulers
|
||||||
import rx.subscriptions.CompositeSubscription
|
import rx.subscriptions.CompositeSubscription
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is the one in charge of downloading chapters.
|
* This class is the one in charge of downloading chapters.
|
||||||
@ -35,28 +34,25 @@ import uy.kohesive.injekt.injectLazy
|
|||||||
* @param context the application context.
|
* @param context the application context.
|
||||||
* @param provider the downloads directory provider.
|
* @param provider the downloads directory provider.
|
||||||
* @param cache the downloads cache, used to add the downloads to the cache after their completion.
|
* @param cache the downloads cache, used to add the downloads to the cache after their completion.
|
||||||
|
* @param sourceManager the source manager.
|
||||||
*/
|
*/
|
||||||
class Downloader(
|
class Downloader(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val provider: DownloadProvider,
|
private val provider: DownloadProvider,
|
||||||
private val cache: DownloadCache
|
private val cache: DownloadCache,
|
||||||
|
private val sourceManager: SourceManager
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store for persisting downloads across restarts.
|
* Store for persisting downloads across restarts.
|
||||||
*/
|
*/
|
||||||
private val store = DownloadStore(context)
|
private val store = DownloadStore(context, sourceManager)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue where active downloads are kept.
|
* Queue where active downloads are kept.
|
||||||
*/
|
*/
|
||||||
val queue = DownloadQueue(store)
|
val queue = DownloadQueue(store)
|
||||||
|
|
||||||
/**
|
|
||||||
* Source manager.
|
|
||||||
*/
|
|
||||||
private val sourceManager: SourceManager by injectLazy()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifier for the downloader state and progress.
|
* Notifier for the downloader state and progress.
|
||||||
*/
|
*/
|
||||||
@ -382,7 +378,7 @@ class Downloader(
|
|||||||
// Else guess from the uri.
|
// Else guess from the uri.
|
||||||
?: context.contentResolver.getType(file.uri)
|
?: context.contentResolver.getType(file.uri)
|
||||||
// Else read magic numbers.
|
// Else read magic numbers.
|
||||||
?: DiskUtil.findImageMime { file.openInputStream() }
|
?: ImageUtil.findImageType { file.openInputStream() }?.mime
|
||||||
|
|
||||||
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
|
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.download.model
|
|||||||
|
|
||||||
import com.jakewharton.rxrelay.PublishRelay
|
import com.jakewharton.rxrelay.PublishRelay
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadStore
|
import eu.kanade.tachiyomi.data.download.DownloadStore
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
@ -40,6 +41,14 @@ class DownloadQueue(
|
|||||||
find { it.chapter.id == chapter.id }?.let { remove(it) }
|
find { it.chapter.id == chapter.id }?.let { remove(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun remove(chapters: List<Chapter>) {
|
||||||
|
for (chapter in chapters) { remove(chapter) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(manga: Manga) {
|
||||||
|
filter { it.manga.id == manga.id }.forEach { remove(it) }
|
||||||
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
queue.forEach { download ->
|
queue.forEach { download ->
|
||||||
download.setStatusSubject(null)
|
download.setStatusSubject(null)
|
||||||
|
@ -0,0 +1,74 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.glide
|
||||||
|
|
||||||
|
import com.bumptech.glide.Priority
|
||||||
|
import com.bumptech.glide.load.DataSource
|
||||||
|
import com.bumptech.glide.load.Options
|
||||||
|
import com.bumptech.glide.load.data.DataFetcher
|
||||||
|
import com.bumptech.glide.load.model.ModelLoader
|
||||||
|
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||||
|
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||||
|
import com.bumptech.glide.signature.ObjectKey
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
class PassthroughModelLoader : ModelLoader<InputStream, InputStream> {
|
||||||
|
|
||||||
|
override fun buildLoadData(
|
||||||
|
model: InputStream,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
options: Options
|
||||||
|
): ModelLoader.LoadData<InputStream>? {
|
||||||
|
return ModelLoader.LoadData(ObjectKey(model), Fetcher(model))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handles(model: InputStream): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
class Fetcher(private val stream: InputStream) : DataFetcher<InputStream> {
|
||||||
|
|
||||||
|
override fun getDataClass(): Class<InputStream> {
|
||||||
|
return InputStream::class.java
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cleanup() {
|
||||||
|
try {
|
||||||
|
stream.close()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDataSource(): DataSource {
|
||||||
|
return DataSource.LOCAL
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancel() {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadData(
|
||||||
|
priority: Priority,
|
||||||
|
callback: DataFetcher.DataCallback<in InputStream>
|
||||||
|
) {
|
||||||
|
callback.onDataReady(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory class for creating [PassthroughModelLoader] instances.
|
||||||
|
*/
|
||||||
|
class Factory : ModelLoaderFactory<InputStream, InputStream> {
|
||||||
|
|
||||||
|
override fun build(
|
||||||
|
multiFactory: MultiModelLoaderFactory
|
||||||
|
): ModelLoader<InputStream, InputStream> {
|
||||||
|
return PassthroughModelLoader()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun teardown() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -37,5 +37,7 @@ class TachiGlideModule : AppGlideModule() {
|
|||||||
|
|
||||||
registry.replace(GlideUrl::class.java, InputStream::class.java, networkFactory)
|
registry.replace(GlideUrl::class.java, InputStream::class.java, networkFactory)
|
||||||
registry.append(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory())
|
registry.append(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory())
|
||||||
|
registry.append(InputStream::class.java, InputStream::class.java, PassthroughModelLoader
|
||||||
|
.Factory())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,8 +31,6 @@ object PreferenceKeys {
|
|||||||
|
|
||||||
const val imageScaleType = "pref_image_scale_type_key"
|
const val imageScaleType = "pref_image_scale_type_key"
|
||||||
|
|
||||||
const val imageDecoder = "image_decoder"
|
|
||||||
|
|
||||||
const val zoomStart = "pref_zoom_start_key"
|
const val zoomStart = "pref_zoom_start_key"
|
||||||
|
|
||||||
const val readerTheme = "pref_reader_theme_key"
|
const val readerTheme = "pref_reader_theme_key"
|
||||||
|
@ -59,8 +59,6 @@ class PreferencesHelper(val context: Context) {
|
|||||||
|
|
||||||
fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
|
fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
|
||||||
|
|
||||||
fun imageDecoder() = rxPrefs.getInteger(Keys.imageDecoder, 0)
|
|
||||||
|
|
||||||
fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1)
|
fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1)
|
||||||
|
|
||||||
fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0)
|
fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0)
|
||||||
|
@ -1,24 +1,22 @@
|
|||||||
package eu.kanade.tachiyomi.source
|
package eu.kanade.tachiyomi.source
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.source.model.*
|
import eu.kanade.tachiyomi.source.model.*
|
||||||
import eu.kanade.tachiyomi.util.ChapterRecognition
|
import eu.kanade.tachiyomi.util.ChapterRecognition
|
||||||
import eu.kanade.tachiyomi.util.DiskUtil
|
import eu.kanade.tachiyomi.util.DiskUtil
|
||||||
import eu.kanade.tachiyomi.util.RarContentProvider
|
import eu.kanade.tachiyomi.util.EpubFile
|
||||||
import eu.kanade.tachiyomi.util.ZipContentProvider
|
import eu.kanade.tachiyomi.util.ImageUtil
|
||||||
import junrar.Archive
|
import junrar.Archive
|
||||||
import junrar.rarfile.FileHeader
|
import junrar.rarfile.FileHeader
|
||||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||||
import org.jsoup.Jsoup
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.util.*
|
import java.util.Comparator
|
||||||
|
import java.util.Locale
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
@ -107,15 +105,11 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
|||||||
if (thumbnail_url == null) {
|
if (thumbnail_url == null) {
|
||||||
val chapters = fetchChapterList(this).toBlocking().first()
|
val chapters = fetchChapterList(this).toBlocking().first()
|
||||||
if (chapters.isNotEmpty()) {
|
if (chapters.isNotEmpty()) {
|
||||||
val uri = fetchPageList(chapters.last()).toBlocking().first().firstOrNull()?.uri
|
try {
|
||||||
if (uri != null) {
|
val dest = updateCover(chapters.last(), this)
|
||||||
val input = context.contentResolver.openInputStream(uri)
|
thumbnail_url = dest?.absolutePath
|
||||||
try {
|
} catch (e: Exception) {
|
||||||
val dest = updateCover(context, this, input)
|
Timber.e(e)
|
||||||
thumbnail_url = dest?.absolutePath
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -135,7 +129,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
|||||||
val chapters = getBaseDirectories(context)
|
val chapters = getBaseDirectories(context)
|
||||||
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
||||||
.flatten()
|
.flatten()
|
||||||
.filter { it.isDirectory || isSupportedFormat(it.extension) }
|
.filter { it.isDirectory || isSupportedFile(it.extension) }
|
||||||
.map { chapterFile ->
|
.map { chapterFile ->
|
||||||
SChapter.create().apply {
|
SChapter.create().apply {
|
||||||
url = "${manga.url}/${chapterFile.name}"
|
url = "${manga.url}/${chapterFile.name}"
|
||||||
@ -150,7 +144,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
|||||||
ChapterRecognition.parseChapterNumber(this, manga)
|
ChapterRecognition.parseChapterNumber(this, manga)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sortedWith(Comparator<SChapter> { c1, c2 ->
|
.sortedWith(Comparator { c1, c2 ->
|
||||||
val c = c2.chapter_number.compareTo(c1.chapter_number)
|
val c = c2.chapter_number.compareTo(c1.chapter_number)
|
||||||
if (c == 0) comparator.compare(c2.name, c1.name) else c
|
if (c == 0) comparator.compare(c2.name, c1.name) else c
|
||||||
})
|
})
|
||||||
@ -159,160 +153,90 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||||
|
return Observable.error(Exception("Unused"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSupportedFile(extension: String): Boolean {
|
||||||
|
return extension.toLowerCase() in setOf("zip", "rar", "cbr", "cbz", "epub")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFormat(chapter: SChapter): Format {
|
||||||
val baseDirs = getBaseDirectories(context)
|
val baseDirs = getBaseDirectories(context)
|
||||||
|
|
||||||
for (dir in baseDirs) {
|
for (dir in baseDirs) {
|
||||||
val chapFile = File(dir, chapter.url)
|
val chapFile = File(dir, chapter.url)
|
||||||
if (!chapFile.exists()) continue
|
if (!chapFile.exists()) continue
|
||||||
|
|
||||||
return Observable.just(getLoader(chapFile).load())
|
return getFormat(chapFile)
|
||||||
}
|
}
|
||||||
|
throw Exception("Chapter not found")
|
||||||
return Observable.error(Exception("Chapter not found"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isSupportedFormat(extension: String): Boolean {
|
private fun getFormat(file: File): Format {
|
||||||
return extension.equals("zip", true) || extension.equals("cbz", true)
|
|
||||||
|| extension.equals("rar", true) || extension.equals("cbr", true)
|
|
||||||
|| extension.equals("epub", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getLoader(file: File): Loader {
|
|
||||||
val extension = file.extension
|
val extension = file.extension
|
||||||
return if (file.isDirectory) {
|
return if (file.isDirectory) {
|
||||||
DirectoryLoader(file)
|
Format.Directory(file)
|
||||||
} else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
|
} else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
|
||||||
ZipLoader(file)
|
Format.Zip(file)
|
||||||
} else if (extension.equals("epub", true)) {
|
|
||||||
EpubLoader(file)
|
|
||||||
} else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
|
} else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
|
||||||
RarLoader(file)
|
Format.Rar(file)
|
||||||
|
} else if (extension.equals("epub", true)) {
|
||||||
|
Format.Epub(file)
|
||||||
} else {
|
} else {
|
||||||
throw Exception("Invalid chapter format")
|
throw Exception("Invalid chapter format")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateCover(chapter: SChapter, manga: SManga): File? {
|
||||||
|
val format = getFormat(chapter)
|
||||||
|
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||||
|
return when (format) {
|
||||||
|
is Format.Directory -> {
|
||||||
|
val entry = format.file.listFiles()
|
||||||
|
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||||
|
.find { !it.isDirectory && ImageUtil.isImage(it.name, { FileInputStream(it) }) }
|
||||||
|
|
||||||
|
entry?.let { updateCover(context, manga, it.inputStream())}
|
||||||
|
}
|
||||||
|
is Format.Zip -> {
|
||||||
|
ZipFile(format.file).use { zip ->
|
||||||
|
val entry = zip.entries().toList()
|
||||||
|
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||||
|
.find { !it.isDirectory && ImageUtil.isImage(it.name, { zip.getInputStream(it) }) }
|
||||||
|
|
||||||
|
entry?.let { updateCover(context, manga, zip.getInputStream(it) )}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Format.Rar -> {
|
||||||
|
Archive(format.file).use { archive ->
|
||||||
|
val entry = archive.fileHeaders
|
||||||
|
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
|
||||||
|
.find { !it.isDirectory && ImageUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
|
||||||
|
|
||||||
|
entry?.let { updateCover(context, manga, archive.getInputStream(it) )}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Format.Epub -> {
|
||||||
|
EpubFile(format.file).use { epub ->
|
||||||
|
val entry = epub.getImagesFromPages()
|
||||||
|
.firstOrNull()
|
||||||
|
?.let { epub.getEntry(it) }
|
||||||
|
|
||||||
|
entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true))
|
private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true))
|
||||||
|
|
||||||
override fun getFilterList() = FilterList(OrderBy())
|
override fun getFilterList() = FilterList(OrderBy())
|
||||||
|
|
||||||
interface Loader {
|
sealed class Format {
|
||||||
fun load(): List<Page>
|
data class Directory(val file: File) : Format()
|
||||||
|
data class Zip(val file: File) : Format()
|
||||||
|
data class Rar(val file: File): Format()
|
||||||
|
data class Epub(val file: File) : Format()
|
||||||
}
|
}
|
||||||
|
|
||||||
class DirectoryLoader(val file: File) : Loader {
|
}
|
||||||
override fun load(): List<Page> {
|
|
||||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
|
||||||
return file.listFiles()
|
|
||||||
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { FileInputStream(it) }) }
|
|
||||||
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
|
||||||
.map { Uri.fromFile(it) }
|
|
||||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ZipLoader(val file: File) : Loader {
|
|
||||||
override fun load(): List<Page> {
|
|
||||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
|
||||||
return ZipFile(file).use { zip ->
|
|
||||||
zip.entries().toList()
|
|
||||||
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { zip.getInputStream(it) }) }
|
|
||||||
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
|
||||||
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/${it.name}") }
|
|
||||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RarLoader(val file: File) : Loader {
|
|
||||||
override fun load(): List<Page> {
|
|
||||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
|
||||||
return Archive(file).use { archive ->
|
|
||||||
archive.fileHeaders
|
|
||||||
.filter { !it.isDirectory && DiskUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
|
|
||||||
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
|
|
||||||
.map { Uri.parse("content://${RarContentProvider.PROVIDER}${file.absolutePath}!-/${it.fileNameString}") }
|
|
||||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class EpubLoader(val file: File) : Loader {
|
|
||||||
|
|
||||||
override fun load(): List<Page> {
|
|
||||||
ZipFile(file).use { zip ->
|
|
||||||
val allEntries = zip.entries().toList()
|
|
||||||
val ref = getPackageHref(zip)
|
|
||||||
val doc = getPackageDocument(zip, ref)
|
|
||||||
val pages = getPagesFromDocument(doc)
|
|
||||||
val hrefs = getHrefMap(ref, allEntries.map { it.name })
|
|
||||||
return getImagesFromPages(zip, pages, hrefs)
|
|
||||||
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/$it") }
|
|
||||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the path to the package document.
|
|
||||||
*/
|
|
||||||
private fun getPackageHref(zip: ZipFile): String {
|
|
||||||
val meta = zip.getEntry("META-INF/container.xml")
|
|
||||||
if (meta != null) {
|
|
||||||
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
|
|
||||||
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
|
|
||||||
if (path != null) {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "OEBPS/content.opf"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the package document where all the files are listed.
|
|
||||||
*/
|
|
||||||
private fun getPackageDocument(zip: ZipFile, ref: String): Document {
|
|
||||||
val entry = zip.getEntry(ref)
|
|
||||||
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all the pages from the epub.
|
|
||||||
*/
|
|
||||||
private fun getPagesFromDocument(document: Document): List<String> {
|
|
||||||
val pages = document.select("manifest > item")
|
|
||||||
.filter { "application/xhtml+xml" == it.attr("media-type") }
|
|
||||||
.associateBy { it.attr("id") }
|
|
||||||
|
|
||||||
val spine = document.select("spine > itemref").map { it.attr("idref") }
|
|
||||||
return spine.mapNotNull { pages[it] }.map { it.attr("href") }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all the images contained in every page from the epub.
|
|
||||||
*/
|
|
||||||
private fun getImagesFromPages(zip: ZipFile, pages: List<String>, hrefs: Map<String, String>): List<String> {
|
|
||||||
return pages.map { page ->
|
|
||||||
val entry = zip.getEntry(hrefs[page])
|
|
||||||
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
|
||||||
document.getElementsByTag("img").mapNotNull { hrefs[it.attr("src")] }
|
|
||||||
}.flatten()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a map with a relative url as key and abolute url as path.
|
|
||||||
*/
|
|
||||||
private fun getHrefMap(packageHref: String, entries: List<String>): Map<String, String> {
|
|
||||||
val lastSlashPos = packageHref.lastIndexOf('/')
|
|
||||||
if (lastSlashPos < 0) {
|
|
||||||
return entries.associateBy { it }
|
|
||||||
}
|
|
||||||
return entries.associateBy { entry ->
|
|
||||||
if (entry.isNotBlank() && entry.length > lastSlashPos) {
|
|
||||||
entry.substring(lastSlashPos + 1)
|
|
||||||
} else {
|
|
||||||
entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -2,21 +2,18 @@ package eu.kanade.tachiyomi.source.model
|
|||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import eu.kanade.tachiyomi.network.ProgressListener
|
import eu.kanade.tachiyomi.network.ProgressListener
|
||||||
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
|
|
||||||
import rx.subjects.Subject
|
import rx.subjects.Subject
|
||||||
|
|
||||||
class Page(
|
open class Page(
|
||||||
val index: Int,
|
val index: Int,
|
||||||
val url: String = "",
|
val url: String = "",
|
||||||
var imageUrl: String? = null,
|
var imageUrl: String? = null,
|
||||||
@Transient var uri: Uri? = null
|
@Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
|
||||||
) : ProgressListener {
|
) : ProgressListener {
|
||||||
|
|
||||||
val number: Int
|
val number: Int
|
||||||
get() = index + 1
|
get() = index + 1
|
||||||
|
|
||||||
@Transient lateinit var chapter: ReaderChapter
|
|
||||||
|
|
||||||
@Transient @Volatile var status: Int = 0
|
@Transient @Volatile var status: Int = 0
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
|
@ -1,88 +1,15 @@
|
|||||||
package eu.kanade.tachiyomi.source.online
|
package eu.kanade.tachiyomi.source.online
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
|
|
||||||
|
|
||||||
// TODO: this should be handled with a different approach.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chapter cache.
|
|
||||||
*/
|
|
||||||
private val chapterCache: ChapterCache by injectLazy()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an observable with the page list for a chapter. It tries to return the page list from
|
|
||||||
* the local cache, otherwise fallbacks to network.
|
|
||||||
*
|
|
||||||
* @param chapter the chapter whose page list has to be fetched.
|
|
||||||
*/
|
|
||||||
fun HttpSource.fetchPageListFromCacheThenNet(chapter: Chapter): Observable<List<Page>> {
|
|
||||||
return chapterCache
|
|
||||||
.getPageListFromCache(chapter)
|
|
||||||
.onErrorResumeNext { fetchPageList(chapter) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an observable of the page with the downloaded image.
|
|
||||||
*
|
|
||||||
* @param page the page whose source image has to be downloaded.
|
|
||||||
*/
|
|
||||||
fun HttpSource.fetchImageFromCacheThenNet(page: Page): Observable<Page> {
|
|
||||||
return if (page.imageUrl.isNullOrEmpty())
|
|
||||||
getImageUrl(page).flatMap { getCachedImage(it) }
|
|
||||||
else
|
|
||||||
getCachedImage(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun HttpSource.getImageUrl(page: Page): Observable<Page> {
|
fun HttpSource.getImageUrl(page: Page): Observable<Page> {
|
||||||
page.status = Page.LOAD_PAGE
|
page.status = Page.LOAD_PAGE
|
||||||
return fetchImageUrl(page)
|
return fetchImageUrl(page)
|
||||||
.doOnError { page.status = Page.ERROR }
|
.doOnError { page.status = Page.ERROR }
|
||||||
.onErrorReturn { null }
|
.onErrorReturn { null }
|
||||||
.doOnNext { page.imageUrl = it }
|
.doOnNext { page.imageUrl = it }
|
||||||
.map { page }
|
.map { page }
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an observable of the page that gets the image from the chapter or fallbacks to
|
|
||||||
* network and copies it to the cache calling [cacheImage].
|
|
||||||
*
|
|
||||||
* @param page the page.
|
|
||||||
*/
|
|
||||||
fun HttpSource.getCachedImage(page: Page): Observable<Page> {
|
|
||||||
val imageUrl = page.imageUrl ?: return Observable.just(page)
|
|
||||||
|
|
||||||
return Observable.just(page)
|
|
||||||
.flatMap {
|
|
||||||
if (!chapterCache.isImageInCache(imageUrl)) {
|
|
||||||
cacheImage(page)
|
|
||||||
} else {
|
|
||||||
Observable.just(page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.doOnNext {
|
|
||||||
page.uri = Uri.fromFile(chapterCache.getImageFile(imageUrl))
|
|
||||||
page.status = Page.READY
|
|
||||||
}
|
|
||||||
.doOnError { page.status = Page.ERROR }
|
|
||||||
.onErrorReturn { page }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an observable of the page that downloads the image to [ChapterCache].
|
|
||||||
*
|
|
||||||
* @param page the page.
|
|
||||||
*/
|
|
||||||
private fun HttpSource.cacheImage(page: Page): Observable<Page> {
|
|
||||||
page.status = Page.DOWNLOAD_IMAGE
|
|
||||||
return fetchImage(page)
|
|
||||||
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
|
|
||||||
.map { page }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
|
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
|
||||||
|
@ -20,7 +20,7 @@ import rx.schedulers.Schedulers
|
|||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import java.util.*
|
import java.util.Date
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Presenter of [ChaptersController].
|
* Presenter of [ChaptersController].
|
||||||
@ -271,9 +271,8 @@ class ChaptersPresenter(
|
|||||||
* @param chapters the list of chapters to delete.
|
* @param chapters the list of chapters to delete.
|
||||||
*/
|
*/
|
||||||
fun deleteChapters(chapters: List<ChapterItem>) {
|
fun deleteChapters(chapters: List<ChapterItem>) {
|
||||||
Observable.from(chapters)
|
Observable.just(chapters)
|
||||||
.doOnNext { deleteChapter(it) }
|
.doOnNext { deleteChaptersInternal(chapters) }
|
||||||
.toList()
|
|
||||||
.doOnNext { if (onlyDownloaded()) refreshChapters() }
|
.doOnNext { if (onlyDownloaded()) refreshChapters() }
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
@ -283,14 +282,15 @@ class ChaptersPresenter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a chapter from disk. This method is called in a background thread.
|
* Deletes a list of chapters from disk. This method is called in a background thread.
|
||||||
* @param chapter the chapter to delete.
|
* @param chapters the chapters to delete.
|
||||||
*/
|
*/
|
||||||
private fun deleteChapter(chapter: ChapterItem) {
|
private fun deleteChaptersInternal(chapters: List<ChapterItem>) {
|
||||||
downloadManager.queue.remove(chapter)
|
downloadManager.deleteChapters(chapters, manga, source)
|
||||||
downloadManager.deleteChapter(chapter, manga, source)
|
chapters.forEach {
|
||||||
chapter.status = Download.NOT_DOWNLOADED
|
it.status = Download.NOT_DOWNLOADED
|
||||||
chapter.download = null
|
it.download = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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.util.AttributeSet
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
|
||||||
class PageIndicatorTextView(context: Context, attrs: AttributeSet? = null) :
|
/**
|
||||||
AppCompatTextView(context, attrs) {
|
* Page indicator found at the bottom of the reader
|
||||||
|
*/
|
||||||
|
class PageIndicatorTextView(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null
|
||||||
|
) : AppCompatTextView(context, attrs) {
|
||||||
|
|
||||||
private val fillColor = Color.rgb(235, 235, 235)
|
private val fillColor = Color.rgb(235, 235, 235)
|
||||||
private val strokeColor = Color.rgb(45, 45, 45)
|
private val strokeColor = Color.rgb(45, 45, 45)
|
||||||
@ -53,4 +58,4 @@ class PageIndicatorTextView(context: Context, attrs: AttributeSet? = null) :
|
|||||||
isAccessible = true
|
isAccessible = true
|
||||||
}!!
|
}!!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,13 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.reader
|
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
|
||||||
|
|
||||||
class ReaderChapter(c: Chapter) : Chapter by c {
|
|
||||||
|
|
||||||
@Transient var pages: List<Page>? = null
|
|
||||||
|
|
||||||
var isDownloaded: Boolean = false
|
|
||||||
|
|
||||||
var requestedPage: Int = 0
|
|
||||||
}
|
|
@ -1,19 +1,19 @@
|
|||||||
package eu.kanade.tachiyomi.ui.reader
|
package eu.kanade.tachiyomi.ui.reader
|
||||||
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
|
||||||
import android.support.annotation.ColorInt
|
import android.support.annotation.ColorInt
|
||||||
import android.support.v4.app.DialogFragment
|
import android.support.design.widget.BottomSheetBehavior
|
||||||
|
import android.support.design.widget.BottomSheetDialog
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
import android.widget.SeekBar
|
import android.widget.SeekBar
|
||||||
import com.afollestad.materialdialogs.MaterialDialog
|
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
import eu.kanade.tachiyomi.util.plusAssign
|
import eu.kanade.tachiyomi.util.plusAssign
|
||||||
import eu.kanade.tachiyomi.widget.SimpleSeekBarListener
|
import eu.kanade.tachiyomi.widget.SimpleSeekBarListener
|
||||||
import kotlinx.android.synthetic.main.reader_custom_filter_dialog.view.*
|
import kotlinx.android.synthetic.main.reader_color_filter.*
|
||||||
|
import kotlinx.android.synthetic.main.reader_color_filter_sheet.*
|
||||||
import rx.Subscription
|
import rx.Subscription
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
import rx.subscriptions.CompositeSubscription
|
import rx.subscriptions.CompositeSubscription
|
||||||
@ -21,33 +21,18 @@ import uy.kohesive.injekt.injectLazy
|
|||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom dialog which can be used to set overlay value's
|
* Color filter sheet to toggle custom filter and brightness overlay.
|
||||||
*/
|
*/
|
||||||
class ReaderCustomFilterDialog : DialogFragment() {
|
class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activity) {
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** Integer mask of alpha value **/
|
|
||||||
private const val ALPHA_MASK: Long = 0xFF000000
|
|
||||||
|
|
||||||
/** Integer mask of red value **/
|
|
||||||
private const val RED_MASK: Long = 0x00FF0000
|
|
||||||
|
|
||||||
/** Integer mask of green value **/
|
|
||||||
private const val GREEN_MASK: Long = 0x0000FF00
|
|
||||||
|
|
||||||
/** Integer mask of blue value **/
|
|
||||||
private const val BLUE_MASK: Long = 0x000000FF
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides operations to manage preferences
|
|
||||||
*/
|
|
||||||
private val preferences by injectLazy<PreferencesHelper>()
|
private val preferences by injectLazy<PreferencesHelper>()
|
||||||
|
|
||||||
|
private var behavior: BottomSheetBehavior<*>? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscription used for filter overlay
|
* Subscriptions used for this dialog
|
||||||
*/
|
*/
|
||||||
private lateinit var subscriptions: CompositeSubscription
|
private val subscriptions = CompositeSubscription()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscription used for custom brightness overlay
|
* Subscription used for custom brightness overlay
|
||||||
@ -59,34 +44,18 @@ class ReaderCustomFilterDialog : DialogFragment() {
|
|||||||
*/
|
*/
|
||||||
private var customFilterColorSubscription: Subscription? = null
|
private var customFilterColorSubscription: Subscription? = null
|
||||||
|
|
||||||
/**
|
init {
|
||||||
* This method will be called after onCreate(Bundle)
|
val view = activity.layoutInflater.inflate(R.layout.reader_color_filter_sheet, null)
|
||||||
* @param savedState The last saved instance state of the Fragment.
|
setContentView(view)
|
||||||
*/
|
|
||||||
override fun onCreateDialog(savedState: Bundle?): Dialog {
|
|
||||||
val dialog = MaterialDialog.Builder(activity!!)
|
|
||||||
.customView(R.layout.reader_custom_filter_dialog, false)
|
|
||||||
.positiveText(android.R.string.ok)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
subscriptions = CompositeSubscription()
|
behavior = BottomSheetBehavior.from(view.parent as ViewGroup)
|
||||||
onViewCreated(dialog.view, savedState)
|
|
||||||
|
|
||||||
return dialog
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called immediately after onCreateView()
|
|
||||||
* @param view The View returned by onCreateDialog.
|
|
||||||
* @param savedInstanceState If non-null, this fragment is being re-constructed
|
|
||||||
*/
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(view) {
|
|
||||||
// Initialize subscriptions.
|
// Initialize subscriptions.
|
||||||
subscriptions += preferences.colorFilter().asObservable()
|
subscriptions += preferences.colorFilter().asObservable()
|
||||||
.subscribe { setColorFilter(it, view) }
|
.subscribe { setColorFilter(it, view) }
|
||||||
|
|
||||||
subscriptions += preferences.customBrightness().asObservable()
|
subscriptions += preferences.customBrightness().asObservable()
|
||||||
.subscribe { setCustomBrightness(it, view) }
|
.subscribe { setCustomBrightness(it, view) }
|
||||||
|
|
||||||
// Get color and update values
|
// Get color and update values
|
||||||
val color = preferences.colorFilterValue().getOrDefault()
|
val color = preferences.colorFilterValue().getOrDefault()
|
||||||
@ -154,7 +123,19 @@ class ReaderCustomFilterDialog : DialogFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
behavior?.skipCollapsed = true
|
||||||
|
behavior?.state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
subscriptions.unsubscribe()
|
||||||
|
customBrightnessSubscription = null
|
||||||
|
customFilterColorSubscription = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -210,8 +191,8 @@ class ReaderCustomFilterDialog : DialogFragment() {
|
|||||||
private fun setCustomBrightness(enabled: Boolean, view: View) {
|
private fun setCustomBrightness(enabled: Boolean, view: View) {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
customBrightnessSubscription = preferences.customBrightnessValue().asObservable()
|
customBrightnessSubscription = preferences.customBrightnessValue().asObservable()
|
||||||
.sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
|
.sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
|
||||||
.subscribe { setCustomBrightnessValue(it, view) }
|
.subscribe { setCustomBrightnessValue(it, view) }
|
||||||
|
|
||||||
subscriptions.add(customBrightnessSubscription)
|
subscriptions.add(customBrightnessSubscription)
|
||||||
} else {
|
} else {
|
||||||
@ -249,13 +230,13 @@ class ReaderCustomFilterDialog : DialogFragment() {
|
|||||||
private fun setColorFilter(enabled: Boolean, view: View) {
|
private fun setColorFilter(enabled: Boolean, view: View) {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
customFilterColorSubscription = preferences.colorFilterValue().asObservable()
|
customFilterColorSubscription = preferences.colorFilterValue().asObservable()
|
||||||
.sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
|
.sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
|
||||||
.subscribe { setColorFilterValue(it, view) }
|
.subscribe { setColorFilterValue(it, view) }
|
||||||
|
|
||||||
subscriptions.add(customFilterColorSubscription)
|
subscriptions.add(customFilterColorSubscription)
|
||||||
} else {
|
} else {
|
||||||
customFilterColorSubscription?.let { subscriptions.remove(it) }
|
customFilterColorSubscription?.let { subscriptions.remove(it) }
|
||||||
view.color_overlay.visibility = View.GONE
|
color_overlay.visibility = View.GONE
|
||||||
}
|
}
|
||||||
setColorFilterSeekBar(enabled, view)
|
setColorFilterSeekBar(enabled, view)
|
||||||
}
|
}
|
||||||
@ -319,12 +300,18 @@ class ReaderCustomFilterDialog : DialogFragment() {
|
|||||||
return color and 0xFF
|
return color and 0xFF
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private companion object {
|
||||||
* Called when dialog is dismissed
|
/** Integer mask of alpha value **/
|
||||||
*/
|
const val ALPHA_MASK: Long = 0xFF000000
|
||||||
override fun onDestroyView() {
|
|
||||||
subscriptions.unsubscribe()
|
/** Integer mask of red value **/
|
||||||
super.onDestroyView()
|
const val RED_MASK: Long = 0x00FF0000
|
||||||
|
|
||||||
|
/** Integer mask of green value **/
|
||||||
|
const val GREEN_MASK: Long = 0x0000FF00
|
||||||
|
|
||||||
|
/** Integer mask of blue value **/
|
||||||
|
const val BLUE_MASK: Long = 0x000000FF
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -1,6 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.reader
|
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
|
||||||
|
|
||||||
class ReaderEvent(val manga: Manga, val chapter: Chapter)
|
|
@ -0,0 +1,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 used to show BigPictureStyle notifications
|
||||||
*/
|
*/
|
||||||
class SaveImageNotifier(private val context: Context) {
|
class SaveImageNotifier(private val context: Context) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notification builder.
|
* Notification builder.
|
||||||
*/
|
*/
|
||||||
@ -35,12 +36,12 @@ class SaveImageNotifier(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
fun onComplete(file: File) {
|
fun onComplete(file: File) {
|
||||||
val bitmap = GlideApp.with(context)
|
val bitmap = GlideApp.with(context)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.load(file)
|
.load(file)
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
.skipMemoryCache(true)
|
.skipMemoryCache(true)
|
||||||
.submit(720, 1280)
|
.submit(720, 1280)
|
||||||
.get()
|
.get()
|
||||||
|
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
showCompleteNotification(file, bitmap)
|
showCompleteNotification(file, bitmap)
|
||||||
|
@ -0,0 +1,84 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.reader.loader
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
|
import eu.kanade.tachiyomi.source.LocalSource
|
||||||
|
import eu.kanade.tachiyomi.source.Source
|
||||||
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||||
|
import rx.Completable
|
||||||
|
import rx.Observable
|
||||||
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
|
import rx.schedulers.Schedulers
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loader used to retrieve the [PageLoader] for a given chapter.
|
||||||
|
*/
|
||||||
|
class ChapterLoader(
|
||||||
|
private val downloadManager: DownloadManager,
|
||||||
|
private val manga: Manga,
|
||||||
|
private val source: Source
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a completable that assigns the page loader and loads the its pages. It just
|
||||||
|
* completes if the chapter is already loaded.
|
||||||
|
*/
|
||||||
|
fun loadChapter(chapter: ReaderChapter): Completable {
|
||||||
|
if (chapter.state is ReaderChapter.State.Loaded) {
|
||||||
|
return Completable.complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Observable.just(chapter)
|
||||||
|
.doOnNext { chapter.state = ReaderChapter.State.Loading }
|
||||||
|
.observeOn(Schedulers.io())
|
||||||
|
.flatMap {
|
||||||
|
Timber.d("Loading pages for ${chapter.chapter.name}")
|
||||||
|
|
||||||
|
val loader = getPageLoader(it)
|
||||||
|
chapter.pageLoader = loader
|
||||||
|
|
||||||
|
loader.getPages().take(1).doOnNext { pages ->
|
||||||
|
pages.forEach { it.chapter = chapter }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.doOnNext { pages ->
|
||||||
|
if (pages.isEmpty()) {
|
||||||
|
throw Exception("Page list is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
chapter.state = ReaderChapter.State.Loaded(pages)
|
||||||
|
|
||||||
|
// If the chapter is partially read, set the starting page to the last the user read
|
||||||
|
// otherwise use the requested page.
|
||||||
|
if (!chapter.chapter.read) {
|
||||||
|
chapter.requestedPage = chapter.chapter.last_page_read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toCompletable()
|
||||||
|
.doOnError { chapter.state = ReaderChapter.State.Error(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the page loader to use for this [chapter].
|
||||||
|
*/
|
||||||
|
private fun getPageLoader(chapter: ReaderChapter): PageLoader {
|
||||||
|
val isDownloaded = downloadManager.isChapterDownloaded(chapter.chapter, manga, true)
|
||||||
|
return when {
|
||||||
|
isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager)
|
||||||
|
source is HttpSource -> HttpPageLoader(chapter, source)
|
||||||
|
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
|
||||||
|
when (format) {
|
||||||
|
is LocalSource.Format.Directory -> DirectoryPageLoader(format.file)
|
||||||
|
is LocalSource.Format.Zip -> ZipPageLoader(format.file)
|
||||||
|
is LocalSource.Format.Rar -> RarPageLoader(format.file)
|
||||||
|
is LocalSource.Format.Epub -> EpubPageLoader(format.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> error("Loader not implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,43 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.reader.loader
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
|
import eu.kanade.tachiyomi.util.ImageUtil
|
||||||
|
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||||
|
import rx.Observable
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loader used to load a chapter from a directory given on [file].
|
||||||
|
*/
|
||||||
|
class DirectoryPageLoader(val file: File) : PageLoader() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an observable containing the pages found on this directory ordered with a natural
|
||||||
|
* comparator.
|
||||||
|
*/
|
||||||
|
override fun getPages(): Observable<List<ReaderPage>> {
|
||||||
|
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||||
|
|
||||||
|
return file.listFiles()
|
||||||
|
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
|
||||||
|
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||||
|
.mapIndexed { i, file ->
|
||||||
|
val streamFn = { FileInputStream(file) }
|
||||||
|
ReaderPage(i).apply {
|
||||||
|
stream = streamFn
|
||||||
|
status = Page.READY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { Observable.just(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an observable that emits a ready state.
|
||||||
|
*/
|
||||||
|
override fun getPage(page: ReaderPage): Observable<Int> {
|
||||||
|
return Observable.just(Page.READY)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.reader.loader
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
|
import eu.kanade.tachiyomi.source.Source
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loader used to load a chapter from the downloaded chapters.
|
||||||
|
*/
|
||||||
|
class DownloadPageLoader(
|
||||||
|
private val chapter: ReaderChapter,
|
||||||
|
private val manga: Manga,
|
||||||
|
private val source: Source,
|
||||||
|
private val downloadManager: DownloadManager
|
||||||
|
) : PageLoader() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application context. Needed to open input streams.
|
||||||
|
*/
|
||||||
|
private val context by injectLazy<Application>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an observable containing the pages found on this downloaded chapter.
|
||||||
|
*/
|
||||||
|
override fun getPages(): Observable<List<ReaderPage>> {
|
||||||
|
return downloadManager.buildPageList(source, manga, chapter.chapter)
|
||||||
|
.map { pages ->
|
||||||
|
pages.map { page ->
|
||||||
|
ReaderPage(page.index, page.url, page.imageUrl, {
|
||||||
|
context.contentResolver.openInputStream(page.uri)
|
||||||
|
}).apply {
|
||||||
|
status = Page.READY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPage(page: ReaderPage): Observable<Int> {
|
||||||
|
return Observable.just(Page.READY) // TODO maybe check if file still exists?
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,54 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.reader.loader
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
|
import eu.kanade.tachiyomi.util.EpubFile
|
||||||
|
import rx.Observable
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loader used to load a chapter from a .epub file.
|
||||||
|
*/
|
||||||
|
class EpubPageLoader(file: File) : PageLoader() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The epub file.
|
||||||
|
*/
|
||||||
|
private val epub = EpubFile(file)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recycles this loader and the open zip.
|
||||||
|
*/
|
||||||
|
override fun recycle() {
|
||||||
|
super.recycle()
|
||||||
|
epub.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an observable containing the pages found on this zip archive ordered with a natural
|
||||||
|
* comparator.
|
||||||
|
*/
|
||||||
|
override fun getPages(): Observable<List<ReaderPage>> {
|
||||||
|
return epub.getImagesFromPages()
|
||||||
|
.mapIndexed { i, path ->
|
||||||
|
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) }
|
||||||
|
ReaderPage(i).apply {
|
||||||
|
stream = streamFn
|
||||||
|
status = Page.READY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { Observable.just(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an observable that emits a ready state unless the loader was recycled.
|
||||||
|
*/
|
||||||
|
override fun getPage(page: ReaderPage): Observable<Int> {
|
||||||
|
return Observable.just(if (isRecycled) {
|
||||||
|
Page.ERROR
|
||||||
|
} else {
|
||||||
|
Page.READY
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,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
|
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
|
||||||
|
|
||||||
|
import android.support.v7.util.DiffUtil
|
||||||
import android.support.v7.widget.RecyclerView
|
import android.support.v7.widget.RecyclerView
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import eu.kanade.tachiyomi.R
|
import android.widget.FrameLayout
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import android.widget.LinearLayout
|
||||||
import eu.kanade.tachiyomi.util.inflate
|
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapter of pages for a RecyclerView.
|
* RecyclerView Adapter used by this [viewer] to where [ViewerChapters] updates are posted.
|
||||||
*
|
|
||||||
* @param fragment the fragment containing this adapter.
|
|
||||||
*/
|
*/
|
||||||
class WebtoonAdapter(val fragment: WebtoonReader) : RecyclerView.Adapter<WebtoonHolder>() {
|
class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pages stored in the adapter.
|
* List of currently set items.
|
||||||
*/
|
*/
|
||||||
var pages: List<Page>? = null
|
var items: List<Any> = emptyList()
|
||||||
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Touch listener for images in holders.
|
* Updates this adapter with the given [chapters]. It handles setting a few pages of the
|
||||||
|
* next/previous chapter to allow seamless transitions.
|
||||||
*/
|
*/
|
||||||
val touchListener = View.OnTouchListener { _, ev -> fragment.imageGestureDetector.onTouchEvent(ev) }
|
fun setChapters(chapters: ViewerChapters) {
|
||||||
|
val newItems = mutableListOf<Any>()
|
||||||
|
|
||||||
|
// Add previous chapter pages and transition.
|
||||||
|
if (chapters.prevChapter != null) {
|
||||||
|
// We only need to add the last few pages of the previous chapter, because it'll be
|
||||||
|
// selected as the current chapter when one of those pages is selected.
|
||||||
|
val prevPages = chapters.prevChapter.pages
|
||||||
|
if (prevPages != null) {
|
||||||
|
newItems.addAll(prevPages.takeLast(2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
|
||||||
|
|
||||||
|
// Add current chapter.
|
||||||
|
val currPages = chapters.currChapter.pages
|
||||||
|
if (currPages != null) {
|
||||||
|
newItems.addAll(currPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add next chapter transition and pages.
|
||||||
|
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
|
||||||
|
if (chapters.nextChapter != null) {
|
||||||
|
// Add at most two pages, because this chapter will be selected before the user can
|
||||||
|
// swap more pages.
|
||||||
|
val nextPages = chapters.nextChapter.pages
|
||||||
|
if (nextPages != null) {
|
||||||
|
newItems.addAll(nextPages.take(2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = DiffUtil.calculateDiff(Callback(items, newItems))
|
||||||
|
items = newItems
|
||||||
|
result.dispatchUpdatesTo(this)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the number of pages.
|
* Returns the amount of items of the adapter.
|
||||||
*
|
|
||||||
* @return the number of pages or 0 if the list is null.
|
|
||||||
*/
|
*/
|
||||||
override fun getItemCount(): Int {
|
override fun getItemCount(): Int {
|
||||||
return pages?.size ?: 0
|
return items.size
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a page given the position.
|
* Returns the view type for the item at the given [position].
|
||||||
*
|
|
||||||
* @param position the position of the page.
|
|
||||||
* @return the page.
|
|
||||||
*/
|
*/
|
||||||
fun getItem(position: Int): Page {
|
override fun getItemViewType(position: Int): Int {
|
||||||
return pages!![position]
|
val item = items[position]
|
||||||
|
return when (item) {
|
||||||
|
is ReaderPage -> PAGE_VIEW
|
||||||
|
is ChapterTransition -> TRANSITION_VIEW
|
||||||
|
else -> error("Unknown view type for ${item.javaClass}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new view holder.
|
* Creates a new view holder for an item with the given [viewType].
|
||||||
*
|
|
||||||
* @param parent the parent view.
|
|
||||||
* @param viewType the type of the holder.
|
|
||||||
* @return a new view holder for a manga.
|
|
||||||
*/
|
*/
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WebtoonHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
val v = parent.inflate(R.layout.reader_webtoon_item)
|
return when (viewType) {
|
||||||
return WebtoonHolder(v, this)
|
PAGE_VIEW -> {
|
||||||
|
val view = FrameLayout(parent.context)
|
||||||
|
WebtoonPageHolder(view, viewer)
|
||||||
|
}
|
||||||
|
TRANSITION_VIEW -> {
|
||||||
|
val view = LinearLayout(parent.context)
|
||||||
|
WebtoonTransitionHolder(view, viewer)
|
||||||
|
}
|
||||||
|
else -> error("Unknown view type")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Binds a holder with a new position.
|
* Binds an existing view [holder] with the item at the given [position].
|
||||||
*
|
|
||||||
* @param holder the holder to bind.
|
|
||||||
* @param position the position to bind.
|
|
||||||
*/
|
*/
|
||||||
override fun onBindViewHolder(holder: WebtoonHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
val page = getItem(position)
|
val item = items[position]
|
||||||
holder.onSetValues(page)
|
when (holder) {
|
||||||
|
is WebtoonPageHolder -> holder.bind(item as ReaderPage)
|
||||||
|
is WebtoonTransitionHolder -> holder.bind(item as ChapterTransition)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recycles the view holder.
|
* Recycles an existing view [holder] before adding it to the view pool.
|
||||||
*
|
|
||||||
* @param holder the holder to recycle.
|
|
||||||
*/
|
*/
|
||||||
override fun onViewRecycled(holder: WebtoonHolder) {
|
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
|
||||||
holder.onRecycle()
|
when (holder) {
|
||||||
|
is WebtoonPageHolder -> holder.recycle()
|
||||||
|
is WebtoonTransitionHolder -> holder.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff util callback used to dispatch delta updates instead of full dataset changes.
|
||||||
|
*/
|
||||||
|
private class Callback(
|
||||||
|
private val oldItems: List<Any>,
|
||||||
|
private val newItems: List<Any>
|
||||||
|
) : DiffUtil.Callback() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if these two items are the same.
|
||||||
|
*/
|
||||||
|
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||||
|
val oldItem = oldItems[oldItemPosition]
|
||||||
|
val newItem = newItems[newItemPosition]
|
||||||
|
|
||||||
|
return oldItem == newItem
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the contents of the items are the same.
|
||||||
|
*/
|
||||||
|
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the size of the old list.
|
||||||
|
*/
|
||||||
|
override fun getOldListSize(): Int {
|
||||||
|
return oldItems.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the size of the new list.
|
||||||
|
*/
|
||||||
|
override fun getNewListSize(): Int {
|
||||||
|
return newItems.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* View holder type of a chapter page view.
|
||||||
|
*/
|
||||||
|
const val PAGE_VIEW = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View holder type of a chapter transition view.
|
||||||
|
*/
|
||||||
|
const val TRANSITION_VIEW = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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
|
* @param chapters list of chapters
|
||||||
*/
|
*/
|
||||||
fun deleteChapters(chapters: List<RecentChapterItem>) {
|
fun deleteChapters(chapters: List<RecentChapterItem>) {
|
||||||
Observable.from(chapters)
|
Observable.just(chapters)
|
||||||
.doOnNext { deleteChapter(it) }
|
.doOnNext { deleteChaptersInternal(it) }
|
||||||
.toList()
|
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribeFirst({ view, _ ->
|
.subscribeFirst({ view, _ ->
|
||||||
@ -184,16 +183,23 @@ class RecentChaptersPresenter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete selected chapter
|
* Delete selected chapters
|
||||||
*
|
*
|
||||||
* @param item chapter that is selected
|
* @param items chapters selected
|
||||||
*/
|
*/
|
||||||
private fun deleteChapter(item: RecentChapterItem) {
|
private fun deleteChaptersInternal(chapterItems: List<RecentChapterItem>) {
|
||||||
val source = sourceManager.get(item.manga.source) ?: return
|
val itemsByManga = chapterItems.groupBy { it.manga.id }
|
||||||
downloadManager.queue.remove(item.chapter)
|
for ((_, items) in itemsByManga) {
|
||||||
downloadManager.deleteChapter(item.chapter, item.manga, source)
|
val manga = items.first().manga
|
||||||
item.status = Download.NOT_DOWNLOADED
|
val source = sourceManager.get(manga.source) ?: continue
|
||||||
item.download = null
|
val chapters = items.map { it.chapter }
|
||||||
|
|
||||||
|
downloadManager.deleteChapters(chapters, manga, source)
|
||||||
|
items.forEach {
|
||||||
|
it.status = Download.NOT_DOWNLOADED
|
||||||
|
it.download = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -54,14 +54,6 @@ class SettingsReaderController : SettingsController() {
|
|||||||
defaultValue = "0"
|
defaultValue = "0"
|
||||||
summary = "%s"
|
summary = "%s"
|
||||||
}
|
}
|
||||||
intListPreference {
|
|
||||||
key = Keys.imageDecoder
|
|
||||||
titleRes = R.string.pref_image_decoder
|
|
||||||
entries = arrayOf("Image", "Rapid", "Skia")
|
|
||||||
entryValues = arrayOf("0", "1", "2")
|
|
||||||
defaultValue = "0"
|
|
||||||
summary = "%s"
|
|
||||||
}
|
|
||||||
intListPreference {
|
intListPreference {
|
||||||
key = Keys.doubleTapAnimationSpeed
|
key = Keys.doubleTapAnimationSpeed
|
||||||
titleRes = R.string.pref_double_tap_anim_speed
|
titleRes = R.string.pref_double_tap_anim_speed
|
||||||
|
@ -11,6 +11,7 @@ import android.content.pm.PackageManager
|
|||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
|
import android.support.annotation.AttrRes
|
||||||
import android.support.annotation.StringRes
|
import android.support.annotation.StringRes
|
||||||
import android.support.v4.app.NotificationCompat
|
import android.support.v4.app.NotificationCompat
|
||||||
import android.support.v4.content.ContextCompat
|
import android.support.v4.content.ContextCompat
|
||||||
@ -79,7 +80,7 @@ fun Context.hasPermission(permission: String)
|
|||||||
*
|
*
|
||||||
* @param resource the attribute.
|
* @param resource the attribute.
|
||||||
*/
|
*/
|
||||||
fun Context.getResourceColor(@StringRes resource: Int): Int {
|
fun Context.getResourceColor(@AttrRes resource: Int): Int {
|
||||||
val typedArray = obtainStyledAttributes(intArrayOf(resource))
|
val typedArray = obtainStyledAttributes(intArrayOf(resource))
|
||||||
val attrValue = typedArray.getColor(0, 0)
|
val attrValue = typedArray.getColor(0, 0)
|
||||||
typedArray.recycle()
|
typedArray.recycle()
|
||||||
@ -161,4 +162,4 @@ fun Context.isServiceRunning(serviceClass: Class<*>): Boolean {
|
|||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
return manager.getRunningServices(Integer.MAX_VALUE)
|
return manager.getRunningServices(Integer.MAX_VALUE)
|
||||||
.any { className == it.service.className }
|
.any { className == it.service.className }
|
||||||
}
|
}
|
||||||
|
@ -8,47 +8,9 @@ import android.os.Environment
|
|||||||
import android.support.v4.content.ContextCompat
|
import android.support.v4.content.ContextCompat
|
||||||
import android.support.v4.os.EnvironmentCompat
|
import android.support.v4.os.EnvironmentCompat
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
|
||||||
import java.net.URLConnection
|
|
||||||
|
|
||||||
object DiskUtil {
|
object DiskUtil {
|
||||||
|
|
||||||
fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
|
|
||||||
val contentType = try {
|
|
||||||
URLConnection.guessContentTypeFromName(name)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
} ?: openStream?.let { findImageMime(it) }
|
|
||||||
|
|
||||||
return contentType?.startsWith("image/") ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun findImageMime(openStream: () -> InputStream): String? {
|
|
||||||
try {
|
|
||||||
openStream().buffered().use {
|
|
||||||
val bytes = ByteArray(8)
|
|
||||||
it.mark(bytes.size)
|
|
||||||
val length = it.read(bytes, 0, bytes.size)
|
|
||||||
it.reset()
|
|
||||||
if (length == -1)
|
|
||||||
return null
|
|
||||||
if (bytes[0] == 'G'.toByte() && bytes[1] == 'I'.toByte() && bytes[2] == 'F'.toByte() && bytes[3] == '8'.toByte()) {
|
|
||||||
return "image/gif"
|
|
||||||
} else if (bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && bytes[2] == 0x4E.toByte()
|
|
||||||
&& bytes[3] == 0x47.toByte() && bytes[4] == 0x0D.toByte() && bytes[5] == 0x0A.toByte()
|
|
||||||
&& bytes[6] == 0x1A.toByte() && bytes[7] == 0x0A.toByte()) {
|
|
||||||
return "image/png"
|
|
||||||
} else if (bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte()) {
|
|
||||||
return "image/jpeg"
|
|
||||||
} else if (bytes[0] == 'W'.toByte() && bytes[1] == 'E'.toByte() && bytes[2] == 'B'.toByte() && bytes[3] == 'P'.toByte()) {
|
|
||||||
return "image/webp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch(e: Exception) {
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hashKeyForDisk(key: String): String {
|
fun hashKeyForDisk(key: String): String {
|
||||||
return Hash.md5(key)
|
return Hash.md5(key)
|
||||||
}
|
}
|
||||||
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
@ -10,4 +10,8 @@ operator fun CompositeSubscription.plusAssign(subscription: Subscription) = add(
|
|||||||
|
|
||||||
fun <T, U, R> Observable<T>.combineLatest(o2: Observable<U>, combineFn: (T, U) -> R): Observable<R> {
|
fun <T, U, R> Observable<T>.combineLatest(o2: Observable<U>, combineFn: (T, U) -> R): Observable<R> {
|
||||||
return Observable.combineLatest(this, o2, combineFn)
|
return Observable.combineLatest(this, o2, combineFn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Subscription.addTo(subscriptions: CompositeSubscription) {
|
||||||
|
subscriptions.add(this)
|
||||||
|
}
|
||||||
|
@ -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
|
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">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/reader"
|
android:id="@+id/viewer_container"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
android:id="@+id/please_wait"
|
android:id="@+id/please_wait"
|
||||||
style="?android:attr/progressBarStyleLarge"
|
android:layout_width="56dp"
|
||||||
android:layout_width="wrap_content"
|
android:layout_height="56dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_gravity="center"
|
||||||
android:layout_gravity="center"/>
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible"/>
|
||||||
|
|
||||||
<eu.kanade.tachiyomi.ui.reader.PageIndicatorTextView
|
<eu.kanade.tachiyomi.ui.reader.PageIndicatorTextView
|
||||||
android:id="@+id/page_number"
|
android:id="@+id/page_number"
|
||||||
@ -39,6 +40,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:fitsSystemWindows="true"
|
android:fitsSystemWindows="true"
|
||||||
|
android:theme="?attr/actionBarTheme"
|
||||||
android:visibility="invisible"
|
android:visibility="invisible"
|
||||||
tools:visibility="visible">
|
tools:visibility="visible">
|
||||||
|
|
||||||
@ -47,8 +49,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="?attr/actionBarSize"
|
android:layout_height="?attr/actionBarSize"
|
||||||
android:background="?colorPrimary"
|
android:background="?colorPrimary"
|
||||||
android:elevation="4dp"
|
android:elevation="4dp" />
|
||||||
android:theme="?attr/actionBarTheme"/>
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/reader_menu_bottom"
|
android:id="@+id/reader_menu_bottom"
|
||||||
@ -58,7 +59,6 @@
|
|||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:background="?colorPrimary"
|
android:background="?colorPrimary"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:focusable="false"
|
|
||||||
android:descendantFocusability="blocksDescendants">
|
android:descendantFocusability="blocksDescendants">
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
@ -66,35 +66,39 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:padding="@dimen/material_layout_keylines_screen_edge_margin"
|
android:padding="@dimen/material_layout_keylines_screen_edge_margin"
|
||||||
android:background="?android:selectableItemBackground"
|
android:background="?selectableItemBackgroundBorderless"
|
||||||
app:srcCompat="@drawable/ic_skip_previous_white_24dp"/>
|
app:srcCompat="@drawable/ic_skip_previous_white_24dp"/>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/left_page_text"
|
android:id="@+id/left_page_text"
|
||||||
android:layout_width="32dp"
|
android:layout_width="32dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:textSize="15sp"/>
|
android:textSize="15sp"
|
||||||
|
android:clickable="true"
|
||||||
|
tools:text="1"/>
|
||||||
|
|
||||||
<SeekBar
|
<eu.kanade.tachiyomi.ui.reader.ReaderSeekBar
|
||||||
android:id="@+id/page_seekbar"
|
android:id="@+id/page_seekbar"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1"/>
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/right_page_text"
|
android:id="@+id/right_page_text"
|
||||||
android:layout_width="32dp"
|
android:layout_width="32dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:textSize="15sp"/>
|
android:textSize="15sp"
|
||||||
|
android:clickable="true"
|
||||||
|
tools:text="15"/>
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/right_chapter"
|
android:id="@+id/right_chapter"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:padding="@dimen/material_layout_keylines_screen_edge_margin"
|
android:padding="@dimen/material_layout_keylines_screen_edge_margin"
|
||||||
android:background="?android:selectableItemBackground"
|
android:background="?selectableItemBackgroundBorderless"
|
||||||
app:srcCompat="@drawable/ic_skip_next_white_24dp"/>
|
app:srcCompat="@drawable/ic_skip_next_white_24dp"/>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@ -113,4 +117,4 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:visibility="gone"/>
|
android:visibility="gone"/>
|
||||||
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
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