let's hope this compiles!

This commit is contained in:
jhmiramon 2021-04-22 13:29:46 +02:00
parent 5971114cf5
commit 1c98f9181d
144 changed files with 14433 additions and 212 deletions

View file

@ -13,6 +13,7 @@ import com.bumptech.glide.Glide
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.database.models.Episode
import eu.kanade.tachiyomi.data.glide.toAnimeThumbnail
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -302,7 +303,7 @@ class AnimelibUpdateNotifier(private val context: Context) {
}
companion object {
private const val NOTIF_MAX_CHAPTERS = 5
private const val NOTIF_MAX_EPISODES = 5
private const val NOTIF_TITLE_MAX_LEN = 45
private const val NOTIF_ICON_SIZE = 192
}

View file

@ -4,41 +4,41 @@ import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.Query
import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.tables.TrackTable
import eu.kanade.tachiyomi.data.database.models.AnimeTrack
import eu.kanade.tachiyomi.data.database.tables.AnimeTrackTable
import eu.kanade.tachiyomi.data.track.TrackService
interface AnimeTrackQueries : DbProvider {
fun getTracks() = db.get()
.listOfObjects(Track::class.java)
.listOfObjects(AnimeTrack::class.java)
.withQuery(
Query.builder()
.table(TrackTable.TABLE)
.table(AnimeTrackTable.TABLE)
.build()
)
.prepare()
fun getTracks(anime: Anime) = db.get()
.listOfObjects(Track::class.java)
.listOfObjects(AnimeTrack::class.java)
.withQuery(
Query.builder()
.table(TrackTable.TABLE)
.where("${TrackTable.COL_MANGA_ID} = ?")
.table(AnimeTrackTable.TABLE)
.where("${AnimeTrackTable.COL_ANIME_ID} = ?")
.whereArgs(anime.id)
.build()
)
.prepare()
fun insertTrack(track: Track) = db.put().`object`(track).prepare()
fun insertTrack(track: AnimeTrack) = db.put().`object`(track).prepare()
fun insertTracks(tracks: List<Track>) = db.put().objects(tracks).prepare()
fun insertTracks(tracks: List<AnimeTrack>) = db.put().objects(tracks).prepare()
fun deleteTrackForAnime(anime: Anime, sync: TrackService) = db.delete()
.byQuery(
DeleteQuery.builder()
.table(TrackTable.TABLE)
.where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?")
.table(AnimeTrackTable.TABLE)
.where("${AnimeTrackTable.COL_ANIME_ID} = ? AND ${AnimeTrackTable.COL_SYNC_ID} = ?")
.whereArgs(anime.id, sync.id)
.build()
)

View file

@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.data.glide
import com.bumptech.glide.load.Key
import eu.kanade.tachiyomi.data.database.models.Anime
import java.security.MessageDigest
data class AnimeThumbnail(val anime: Anime, val coverLastModified: Long) : Key {
val key = anime.url + coverLastModified
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(key.toByteArray(Key.CHARSET))
}
}
fun Anime.toAnimeThumbnail() = AnimeThumbnail(this, cover_last_modified)

View file

@ -20,8 +20,10 @@ import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.anime.AnimeController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.watcher.WatcherActivity
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.getUriCompat
@ -32,6 +34,8 @@ import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.File
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
import eu.kanade.tachiyomi.data.animelib.AnimelibUpdateService
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
/**
* Global [BroadcastReceiver] that runs on UI thread
@ -223,6 +227,17 @@ class NotificationReceiver : BroadcastReceiver() {
Handler().post { dismissNotification(context, notificationId) }
}
/**
* Method called when user wants to stop a library update
*
* @param context context of application
* @param notificationId id of notification
*/
private fun cancelAnimelibUpdate(context: Context, notificationId: Int) {
AnimelibUpdateService.stop(context)
Handler().post { dismissNotification(context, notificationId) }
}
/**
* Method called when user wants to mark manga chapters as read
*
@ -418,7 +433,7 @@ class NotificationReceiver : BroadcastReceiver() {
* @param episode episode that needs to be opened
*/
internal fun openEpisodePendingActivity(context: Context, anime: Anime, episode: Episode): PendingIntent {
val newIntent = ReaderActivity.newIntent(context, anime, episode)
val newIntent = WatcherActivity.newIntent(context, anime, episode)
return PendingIntent.getActivity(context, anime.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT)
}

View file

@ -25,6 +25,7 @@ object Notifications {
*/
const val CHANNEL_LIBRARY = "library_channel"
const val ID_LIBRARY_PROGRESS = -101
const val ID_ANIMELIB_PROGRESS = -1337101
const val ID_LIBRARY_ERROR = -102
/**
@ -43,7 +44,9 @@ object Notifications {
*/
const val CHANNEL_NEW_CHAPTERS = "new_chapters_channel"
const val ID_NEW_CHAPTERS = -301
const val ID_NEW_EPISODES = -1337301
const val GROUP_NEW_CHAPTERS = "eu.kanade.tachiyomi.NEW_CHAPTERS"
const val GROUP_NEW_EPISODES = "eu.kanade.tachiyomi.NEW_EPISODES"
/**
* Notification channel and ids used by the library updater.

View file

@ -91,6 +91,8 @@ object PreferenceKeys {
const val jumpToChapters = "jump_to_chapters"
const val jumpToEpisodes = "jump_to_episodes"
const val updateOnlyNonCompleted = "pref_update_only_non_completed_key"
const val autoUpdateTrack = "pref_auto_update_manga_sync_key"

View file

@ -130,6 +130,8 @@ class PreferencesHelper(val context: Context) {
fun readerTheme() = flowPrefs.getInt(Keys.readerTheme, 1)
fun watcherTheme() = flowPrefs.getInt(Keys.readerTheme, 1)
fun alwaysShowChapterTransition() = flowPrefs.getBoolean(Keys.alwaysShowChapterTransition, true)
fun cropBorders() = flowPrefs.getBoolean(Keys.cropBorders, false)
@ -164,6 +166,8 @@ class PreferencesHelper(val context: Context) {
fun jumpToChapters() = prefs.getBoolean(Keys.jumpToChapters, false)
fun jumpToEpisodes() = prefs.getBoolean(Keys.jumpToEpisodes, false)
fun updateOnlyNonCompleted() = prefs.getBoolean(Keys.updateOnlyNonCompleted, false)
fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true)

View file

@ -5,6 +5,7 @@ import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.models.AnimeTrack
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.NetworkHelper
@ -46,16 +47,24 @@ abstract class TrackService(val id: Int) {
abstract fun displayScore(track: Track): String
abstract fun displayScore(track: AnimeTrack): String
abstract suspend fun add(track: Track): Track
abstract suspend fun update(track: Track): Track
abstract suspend fun updateAnime(track: AnimeTrack): AnimeTrack
abstract suspend fun bind(track: Track): Track
abstract suspend fun bindAnime(track: AnimeTrack): AnimeTrack
abstract suspend fun search(query: String): List<TrackSearch>
abstract suspend fun refresh(track: Track): Track
abstract suspend fun refreshAnime(track: AnimeTrack): AnimeTrack
abstract suspend fun login(username: String, password: String)
@CallSuper

View file

@ -0,0 +1,46 @@
package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.AnimesPage
import rx.Observable
interface AnimeCatalogueSource : AnimeSource {
/**
* An ISO 639-1 compliant language code (two letters in lower case).
*/
override val lang: String
/**
* Whether the source has support for latest updates.
*/
val supportsLatest: Boolean
/**
* Returns an observable containing a page with a list of anime.
*
* @param page the page number to retrieve.
*/
fun fetchPopularAnime(page: Int): Observable<AnimesPage>
/**
* Returns an observable containing a page with a list of anime.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
fun fetchSearchAnime(page: Int, query: String, filters: FilterList): Observable<AnimesPage>
/**
* Returns an observable containing a page with a list of latest anime updates.
*
* @param page the page number to retrieve.
*/
fun fetchLatestUpdates(page: Int): Observable<AnimesPage>
/**
* Returns the list of filters for the source.
*/
fun getFilterList(): FilterList
}

View file

@ -0,0 +1,88 @@
package eu.kanade.tachiyomi.source
import android.graphics.drawable.Drawable
import eu.kanade.tachiyomi.data.database.models.Episode
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.util.lang.awaitSingle
import rx.Observable
import tachiyomi.source.model.EpisodeInfo
import tachiyomi.source.model.AnimeInfo
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* A basic interface for creating a source. It could be an online source, a local source, etc...
*/
interface AnimeSource : tachiyomi.source.AnimeSource {
/**
* Id for the source. Must be unique.
*/
override val id: Long
/**
* Name of the source.
*/
override val name: String
override val lang: String
get() = ""
/**
* Returns an observable with the updated details for a anime.
*
* @param anime the anime to update.
*/
@Deprecated("Use getAnimeDetails instead")
fun fetchAnimeDetails(anime: SAnime): Observable<SAnime>
/**
* Returns an observable with all the available chapters for a anime.
*
* @param anime the anime to update.
*/
@Deprecated("Use getEpisodeList instead")
fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>>
/**
* Returns an observable with the list of pages a chapter has.
*
* @param chapter the chapter.
*/
@Deprecated("Use getPageList instead")
fun fetchPageList(episode: SEpisode): Observable<List<Page>>
/**
* [1.x API] Get the updated details for a anime.
*/
@Suppress("DEPRECATION")
override suspend fun getAnimeDetails(anime: AnimeInfo): AnimeInfo {
val sAnime = anime.toSAnime()
val networkAnime = fetchAnimeDetails(sAnime).awaitSingle()
sAnime.copyFrom(networkAnime)
return sAnime.toAnimeInfo()
}
/**
* [1.x API] Get all the available chapters for a anime.
*/
@Suppress("DEPRECATION")
override suspend fun getEpisodeList(anime: AnimeInfo): List<EpisodeInfo> {
return fetchEpisodeList(anime.toSAnime()).awaitSingle()
.map { it.toEpisodeInfo() }
}
/**
* [1.x API] Get the list of pages a chapter has.
*/
@Suppress("DEPRECATION")
override suspend fun getPageList(episode: EpisodeInfo): List<tachiyomi.source.model.Page> {
return fetchPageList(episode.toSEpisode()).awaitSingle()
.map { it.toPageUrl() }
}
}
fun Source.iconAnime(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this)
fun Source.getPreferenceKeyAnime(): String = "source_$id"

View file

@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.AnimesPage
import rx.Observable
interface CatalogueSource : Source {
@ -24,13 +23,6 @@ interface CatalogueSource : Source {
*/
fun fetchPopularManga(page: Int): Observable<MangasPage>
/**
* Returns an observable containing a page with a list of anime.
*
* @param page the page number to retrieve.
*/
fun fetchPopularAnime(page: Int): Observable<AnimesPage>
/**
* Returns an observable containing a page with a list of manga.
*
@ -40,15 +32,6 @@ interface CatalogueSource : Source {
*/
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage>
/**
* Returns an observable containing a page with a list of anime.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
fun fetchSearchAnime(page: Int, query: String, filters: FilterList): Observable<AnimesPage>
/**
* Returns an observable containing a page with a list of latest manga updates.
*
@ -56,15 +39,8 @@ interface CatalogueSource : Source {
*/
fun fetchLatestUpdates(page: Int): Observable<MangasPage>
/**
* Returns an observable containing a page with a list of latest anime updates.
*
* @param page the page number to retrieve.
*/
fun fetchLatestAnimeUpdates(page: Int): Observable<AnimesPage>
/**
* Returns the list of filters for the source.
*/
fun getFilterList(): FilterList
}
}

View file

@ -0,0 +1,324 @@
package eu.kanade.tachiyomi.source
import android.content.Context
import com.github.junrar.Archive
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.AnimesPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SEpisode
import eu.kanade.tachiyomi.source.model.SAnime
import eu.kanade.tachiyomi.util.episode.EpisodeRecognition
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.AnimeFile
import eu.kanade.tachiyomi.util.system.ImageUtil
import rx.Observable
import timber.log.Timber
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile
class LocalAnimeSource(private val context: Context) : AnimeCatalogueSource {
companion object {
const val ID = 0L
const val HELP_URL = "https://tachiyomi.org/help/guides/local-anime/"
private const val COVER_NAME = "cover.jpg"
private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub")
private val POPULAR_FILTERS = FilterList(OrderBy())
private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) })
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
fun updateCover(context: Context, anime: SAnime, input: InputStream): File? {
val dir = getBaseDirectories(context).firstOrNull()
if (dir == null) {
input.close()
return null
}
val cover = File("${dir.absolutePath}/${anime.url}", COVER_NAME)
// It might not exist if using the external SD card
cover.parentFile?.mkdirs()
input.use {
cover.outputStream().use {
input.copyTo(it)
}
}
return cover
}
private fun getBaseDirectories(context: Context): List<File> {
val c = context.getString(R.string.app_name) + File.separator + "local"
return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) }
}
}
override val id = ID
override val name = context.getString(R.string.local_source)
override val lang = ""
override val supportsLatest = true
override fun toString() = context.getString(R.string.local_source)
override fun fetchPopularAnime(page: Int) = fetchSearchAnime(page, "", POPULAR_FILTERS)
override fun fetchSearchAnime(page: Int, query: String, filters: FilterList): Observable<AnimesPage> {
val baseDirs = getBaseDirectories(context)
val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
var animeDirs = baseDirs
.asSequence()
.mapNotNull { it.listFiles()?.toList() }
.flatten()
.filter { it.isDirectory }
.filterNot { it.name.startsWith('.') }
.filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
.distinctBy { it.name }
val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state
when (state?.index) {
0 -> {
animeDirs = if (state.ascending) {
animeDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) }
} else {
animeDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) }
}
}
1 -> {
animeDirs = if (state.ascending) {
animeDirs.sortedBy(File::lastModified)
} else {
animeDirs.sortedByDescending(File::lastModified)
}
}
}
val animes = animeDirs.map { animeDir ->
SAnime.create().apply {
title = animeDir.name
url = animeDir.name
// Try to find the cover
for (dir in baseDirs) {
val cover = File("${dir.absolutePath}/$url", COVER_NAME)
if (cover.exists()) {
thumbnail_url = cover.absolutePath
break
}
}
val episodes = fetchEpisodeList(this).toBlocking().first()
if (episodes.isNotEmpty()) {
val episode = episodes.last()
val format = getFormat(episode)
if (format is Format.Anime) {
AnimeFile(format.file).use { epub ->
epub.fillAnimeMetadata(this)
}
}
// Copy the cover from the first episode found.
if (thumbnail_url == null) {
try {
val dest = updateCover(episode, this)
thumbnail_url = dest?.absolutePath
} catch (e: Exception) {
Timber.e(e)
}
}
}
}
}
return Observable.just(AnimesPage(animes.toList(), false))
}
override fun fetchLatestUpdates(page: Int) = fetchSearchAnime(page, "", LATEST_FILTERS)
override fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> {
getBaseDirectories(context)
.asSequence()
.mapNotNull { File(it, anime.url).listFiles()?.toList() }
.flatten()
.firstOrNull { it.extension == "json" }
?.apply {
val reader = this.inputStream().bufferedReader()
val json = JsonParser.parseReader(reader).asJsonObject
anime.title = json["title"]?.asString ?: anime.title
anime.author = json["author"]?.asString ?: anime.author
anime.artist = json["artist"]?.asString ?: anime.artist
anime.description = json["description"]?.asString ?: anime.description
anime.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString }
?: anime.genre
anime.status = json["status"]?.asInt ?: anime.status
}
return Observable.just(anime)
}
override fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> {
val episodes = getBaseDirectories(context)
.asSequence()
.mapNotNull { File(it, anime.url).listFiles()?.toList() }
.flatten()
.filter { it.isDirectory || isSupportedFile(it.extension) }
.map { episodeFile ->
SEpisode.create().apply {
url = "${anime.url}/${episodeFile.name}"
name = if (episodeFile.isDirectory) {
episodeFile.name
} else {
episodeFile.nameWithoutExtension
}
date_upload = episodeFile.lastModified()
val format = getFormat(this)
if (format is Format.Anime) {
AnimeFile(format.file).use { epub ->
epub.fillEpisodeMetadata(this)
}
}
val chapNameCut = stripAnimeTitle(name, anime.title)
if (chapNameCut.isNotEmpty()) name = chapNameCut
EpisodeRecognition.parseEpisodeNumber(this, anime)
}
}
.sortedWith(
Comparator { c1, c2 ->
val c = c2.episode_number.compareTo(c1.episode_number)
if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
}
)
.toList()
return Observable.just(episodes)
}
/**
* Strips the anime title from a episode name, matching only based on alphanumeric and whitespace
* characters.
*/
private fun stripAnimeTitle(episodeName: String, animeTitle: String): String {
var episodeNameIndex = 0
var animeTitleIndex = 0
while (episodeNameIndex < episodeName.length && animeTitleIndex < animeTitle.length) {
val episodeChar = episodeName[episodeNameIndex]
val animeChar = animeTitle[animeTitleIndex]
if (!episodeChar.equals(animeChar, true)) {
val invalidEpisodeChar = !episodeChar.isLetterOrDigit() && !episodeChar.isWhitespace()
val invalidAnimeChar = !animeChar.isLetterOrDigit() && !animeChar.isWhitespace()
if (!invalidEpisodeChar && !invalidAnimeChar) {
return episodeName
}
if (invalidEpisodeChar) {
episodeNameIndex++
}
if (invalidAnimeChar) {
animeTitleIndex++
}
} else {
episodeNameIndex++
animeTitleIndex++
}
}
return episodeName.substring(episodeNameIndex).trimStart(' ', '-', '_', ',', ':')
}
override fun fetchPageList(episode: SEpisode): Observable<List<Page>> {
return Observable.error(Exception("Unused"))
}
private fun isSupportedFile(extension: String): Boolean {
return extension.toLowerCase() in SUPPORTED_ARCHIVE_TYPES
}
fun getFormat(episode: SEpisode): Format {
val baseDirs = getBaseDirectories(context)
for (dir in baseDirs) {
val chapFile = File(dir, episode.url)
if (!chapFile.exists()) continue
return getFormat(chapFile)
}
throw Exception("Episode not found")
}
private fun getFormat(file: File): Format {
val extension = file.extension
return if (file.isDirectory) {
Format.Directory(file)
} else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
Format.Zip(file)
} else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
Format.Rar(file)
} else if (extension.equals("epub", true)) {
Format.Anime(file)
} else {
throw Exception("Invalid episode format")
}
}
private fun updateCover(episode: SEpisode, anime: SAnime): File? {
return when (val format = getFormat(episode)) {
is Format.Directory -> {
val entry = format.file.listFiles()
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
entry?.let { updateCover(context, anime, it.inputStream()) }
}
is Format.Zip -> {
ZipFile(format.file).use { zip ->
val entry = zip.entries().toList()
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
entry?.let { updateCover(context, anime, zip.getInputStream(it)) }
}
}
is Format.Rar -> {
Archive(format.file).use { archive ->
val entry = archive.fileHeaders
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
entry?.let { updateCover(context, anime, archive.getInputStream(it)) }
}
}
is Format.Anime -> {
AnimeFile(format.file).use { epub ->
val entry = epub.getImagesFromPages()
.firstOrNull()
?.let { epub.getEntry(it) }
entry?.let { updateCover(context, anime, epub.getInputStream(it)) }
}
}
}
}
private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Selection(0, true))
override fun getFilterList() = FilterList(OrderBy())
sealed class Format {
data class Directory(val file: File) : Format()
data class Zip(val file: File) : Format()
data class Rar(val file: File) : Format()
data class Anime(val file: File) : Format()
}
}

View file

@ -74,13 +74,13 @@ class LocalSource(private val context: Context) : CatalogueSource {
val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
var mangaDirs = baseDirs
.asSequence()
.mapNotNull { it.listFiles()?.toList() }
.flatten()
.filter { it.isDirectory }
.filterNot { it.name.startsWith('.') }
.filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
.distinctBy { it.name }
.asSequence()
.mapNotNull { it.listFiles()?.toList() }
.flatten()
.filter { it.isDirectory }
.filterNot { it.name.startsWith('.') }
.filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
.distinctBy { it.name }
val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state
when (state?.index) {
@ -144,61 +144,61 @@ class LocalSource(private val context: Context) : CatalogueSource {
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
getBaseDirectories(context)
.asSequence()
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
.flatten()
.firstOrNull { it.extension == "json" }
?.apply {
val reader = this.inputStream().bufferedReader()
val json = JsonParser.parseReader(reader).asJsonObject
.asSequence()
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
.flatten()
.firstOrNull { it.extension == "json" }
?.apply {
val reader = this.inputStream().bufferedReader()
val json = JsonParser.parseReader(reader).asJsonObject
manga.title = json["title"]?.asString ?: manga.title
manga.author = json["author"]?.asString ?: manga.author
manga.artist = json["artist"]?.asString ?: manga.artist
manga.description = json["description"]?.asString ?: manga.description
manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString }
?: manga.genre
manga.status = json["status"]?.asInt ?: manga.status
}
manga.title = json["title"]?.asString ?: manga.title
manga.author = json["author"]?.asString ?: manga.author
manga.artist = json["artist"]?.asString ?: manga.artist
manga.description = json["description"]?.asString ?: manga.description
manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString }
?: manga.genre
manga.status = json["status"]?.asInt ?: manga.status
}
return Observable.just(manga)
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
val chapters = getBaseDirectories(context)
.asSequence()
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
.flatten()
.filter { it.isDirectory || isSupportedFile(it.extension) }
.map { chapterFile ->
SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}"
name = if (chapterFile.isDirectory) {
chapterFile.name
} else {
chapterFile.nameWithoutExtension
}
date_upload = chapterFile.lastModified()
val format = getFormat(this)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillChapterMetadata(this)
.asSequence()
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
.flatten()
.filter { it.isDirectory || isSupportedFile(it.extension) }
.map { chapterFile ->
SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}"
name = if (chapterFile.isDirectory) {
chapterFile.name
} else {
chapterFile.nameWithoutExtension
}
}
date_upload = chapterFile.lastModified()
val chapNameCut = stripMangaTitle(name, manga.title)
if (chapNameCut.isNotEmpty()) name = chapNameCut
ChapterRecognition.parseChapterNumber(this, manga)
val format = getFormat(this)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillChapterMetadata(this)
}
}
val chapNameCut = stripMangaTitle(name, manga.title)
if (chapNameCut.isNotEmpty()) name = chapNameCut
ChapterRecognition.parseChapterNumber(this, manga)
}
}
}
.sortedWith(
Comparator { c1, c2 ->
val c = c2.chapter_number.compareTo(c1.chapter_number)
if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
}
)
.toList()
.sortedWith(
Comparator { c1, c2 ->
val c = c2.chapter_number.compareTo(c1.chapter_number)
if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
}
)
.toList()
return Observable.just(chapters)
}
@ -276,16 +276,16 @@ class LocalSource(private val context: Context) : CatalogueSource {
return when (val format = getFormat(chapter)) {
is Format.Directory -> {
val entry = format.file.listFiles()
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(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 { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
entry?.let { updateCover(context, manga, zip.getInputStream(it)) }
}
@ -293,8 +293,8 @@ class LocalSource(private val context: Context) : CatalogueSource {
is Format.Rar -> {
Archive(format.file).use { archive ->
val entry = archive.fileHeaders
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
entry?.let { updateCover(context, manga, archive.getInputStream(it)) }
}
@ -302,8 +302,8 @@ class LocalSource(private val context: Context) : CatalogueSource {
is Format.Epub -> {
EpubFile(format.file).use { epub ->
val entry = epub.getImagesFromPages()
.firstOrNull()
?.let { epub.getEntry(it) }
.firstOrNull()
?.let { epub.getEntry(it) }
entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
}
@ -321,4 +321,4 @@ class LocalSource(private val context: Context) : CatalogueSource {
data class Rar(val file: File) : Format()
data class Epub(val file: File) : Format()
}
}
}

View file

@ -4,9 +4,7 @@ import android.graphics.drawable.Drawable
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SEpisode
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.SAnime
import eu.kanade.tachiyomi.source.model.toChapterInfo
import eu.kanade.tachiyomi.source.model.toMangaInfo
import eu.kanade.tachiyomi.source.model.toPageUrl
@ -45,14 +43,6 @@ interface Source : tachiyomi.source.Source {
@Deprecated("Use getMangaDetails instead")
fun fetchMangaDetails(manga: SManga): Observable<SManga>
/**
* Returns an observable with the updated details for a manga.
*
* @param anime the manga to update.
*/
@Deprecated("Use getAnimeDetails instead")
fun fetchAnimeDetails(anime: SAnime): Observable<SAnime>
/**
* Returns an observable with all the available chapters for a manga.
*
@ -61,14 +51,6 @@ interface Source : tachiyomi.source.Source {
@Deprecated("Use getChapterList instead")
fun fetchChapterList(manga: SManga): Observable<List<SChapter>>
/**
* Returns an observable with all the available chapters for a manga.
*
* @param anime the manga to update.
*/
@Deprecated("Use getEpisodeList instead")
fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>>
/**
* Returns an observable with the list of pages a chapter has.
*
@ -77,14 +59,6 @@ interface Source : tachiyomi.source.Source {
@Deprecated("Use getPageList instead")
fun fetchPageList(chapter: SChapter): Observable<List<Page>>
/**
* Returns an observable with the list of pages a episode has.
*
* @param episode the episode.
*/
@Deprecated("Use getPageList instead")
fun fetchAnimePageList(episode: SEpisode): Observable<List<Page>>
/**
* [1.x API] Get the updated details for a manga.
*/
@ -102,7 +76,7 @@ interface Source : tachiyomi.source.Source {
@Suppress("DEPRECATION")
override suspend fun getChapterList(manga: MangaInfo): List<ChapterInfo> {
return fetchChapterList(manga.toSManga()).awaitSingle()
.map { it.toChapterInfo() }
.map { it.toChapterInfo() }
}
/**
@ -111,10 +85,10 @@ interface Source : tachiyomi.source.Source {
@Suppress("DEPRECATION")
override suspend fun getPageList(chapter: ChapterInfo): List<tachiyomi.source.model.Page> {
return fetchPageList(chapter.toSChapter()).awaitSingle()
.map { it.toPageUrl() }
.map { it.toPageUrl() }
}
}
fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this)
fun Source.getPreferenceKey(): String = "source_$id"
fun Source.getPreferenceKey(): String = "source_$id"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,797 @@
package eu.kanade.tachiyomi.ui.anime
import android.content.Context
import android.net.Uri
import android.os.Bundle
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.cache.AnimeCoverCache
import eu.kanade.tachiyomi.data.database.AnimeDatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Episode
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.database.models.AnimeCategory
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.AnimeTrack
import eu.kanade.tachiyomi.data.database.models.toAnimeInfo
import eu.kanade.tachiyomi.data.download.AnimeDownloadManager
import eu.kanade.tachiyomi.data.download.model.AnimeDownload
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.toSEpisode
import eu.kanade.tachiyomi.source.model.toSAnime
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.anime.episode.EpisodeItem
import eu.kanade.tachiyomi.ui.anime.track.TrackItem
import eu.kanade.tachiyomi.util.*
import eu.kanade.tachiyomi.util.episode.EpisodeSettingsHelper
import eu.kanade.tachiyomi.util.episode.syncEpisodesWithSource
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.supervisorScope
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date
class AnimePresenter(
val anime: Anime,
val source: Source,
val preferences: PreferencesHelper = Injekt.get(),
private val db: AnimeDatabaseHelper = Injekt.get(),
private val trackManager: TrackManager = Injekt.get(),
private val downloadManager: AnimeDownloadManager = Injekt.get(),
private val coverCache: AnimeCoverCache = Injekt.get()
) : BasePresenter<AnimeController>() {
/**
* Subscription to update the anime from the source.
*/
private var fetchAnimeJob: Job? = null
/**
* List of episodes of the anime. It's always unfiltered and unsorted.
*/
var episodes: List<EpisodeItem> = emptyList()
private set
/**
* Subject of list of episodes to allow updating the view without going to DB.
*/
private val episodesRelay: PublishRelay<List<EpisodeItem>> by lazy {
PublishRelay.create<List<EpisodeItem>>()
}
/**
* Whether the episode list has been requested to the source.
*/
var hasRequested = false
private set
/**
* Subscription to retrieve the new list of episodes from the source.
*/
private var fetchEpisodesJob: Job? = null
/**
* Subscription to observe download status changes.
*/
private var observeDownloadsStatusSubscription: Subscription? = null
private var observeDownloadsPageSubscription: Subscription? = null
private var _trackList: List<TrackItem> = emptyList()
val trackList get() = _trackList
private val loggedServices by lazy { trackManager.services.filter { it.isLogged } }
private var trackSubscription: Subscription? = null
private var searchTrackerJob: Job? = null
private var refreshTrackersJob: Job? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
if (!anime.favorite) {
EpisodeSettingsHelper.applySettingDefaults(anime)
}
// Anime info - start
getAnimeObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache({ view, anime -> view.onNextAnimeInfo(anime, source) })
getTrackingObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(AnimeController::onTrackingCount) { _, error -> Timber.e(error) }
// Prepare the relay.
episodesRelay.flatMap { applyEpisodeFilters(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(AnimeController::onNextEpisodes) { _, error -> Timber.e(error) }
// Anime info - end
// Episodes list - start
// Add the subscription that retrieves the episodes from the database, keeps subscribed to
// changes, and sends the list of episodes to the relay.
add(
db.getEpisodes(anime).asRxObservable()
.map { episodes ->
// Convert every episode to a model.
episodes.map { it.toModel() }
}
.doOnNext { episodes ->
// Find downloaded episodes
setDownloadedEpisodes(episodes)
// Store the last emission
this.episodes = episodes
// Listen for download status changes
observeDownloads()
}
.subscribe { episodesRelay.call(it) }
)
// Episodes list - end
fetchTrackers()
}
// Anime info - start
private fun getAnimeObservable(): Observable<Anime> {
return db.getAnime(anime.url, anime.source).asRxObservable()
}
private fun getTrackingObservable(): Observable<Int> {
if (!trackManager.hasLoggedServices()) {
return Observable.just(0)
}
return db.getTracks(anime).asRxObservable()
.map { tracks ->
val loggedServices = trackManager.services.filter { it.isLogged }.map { it.id }
tracks.filter { it.sync_id in loggedServices }
}
.map { it.size }
}
/**
* Fetch anime information from source.
*/
fun fetchAnimeFromSource(manualFetch: Boolean = false) {
if (fetchAnimeJob?.isActive == true) return
fetchAnimeJob = presenterScope.launchIO {
try {
val networkAnime = source.getAnimeDetails(anime.toAnimeInfo())
val sAnime = networkAnime.toSAnime()
anime.prepUpdateCover(coverCache, sAnime, manualFetch)
anime.copyFrom(sAnime)
anime.initialized = true
db.insertAnime(anime).executeAsBlocking()
withUIContext { view?.onFetchAnimeInfoDone() }
} catch (e: Throwable) {
withUIContext { view?.onFetchAnimeInfoError(e) }
}
}
}
/**
* Update favorite status of anime, (removes / adds) anime (to / from) library.
*
* @return the new status of the anime.
*/
fun toggleFavorite(): Boolean {
anime.favorite = !anime.favorite
anime.date_added = when (anime.favorite) {
true -> Date().time
false -> 0
}
if (!anime.favorite) {
anime.removeCovers(coverCache)
}
db.insertAnime(anime).executeAsBlocking()
return anime.favorite
}
/**
* Returns true if the anime has any downloads.
*/
fun hasDownloads(): Boolean {
return downloadManager.getDownloadCount(anime) > 0
}
/**
* Deletes all the downloads for the anime.
*/
fun deleteDownloads() {
downloadManager.deleteAnime(anime, source)
}
/**
* Get user categories.
*
* @return List of categories, not including the default category
*/
fun getCategories(): List<Category> {
return db.getCategories().executeAsBlocking()
}
/**
* Gets the category id's the anime is in, if the anime is not in a category, returns the default id.
*
* @param anime the anime to get categories from.
* @return Array of category ids the anime is in, if none returns default id
*/
fun getAnimeCategoryIds(anime: Anime): Array<Int> {
val categories = db.getCategoriesForAnime(anime).executeAsBlocking()
return categories.mapNotNull { it.id }.toTypedArray()
}
/**
* Move the given anime to categories.
*
* @param anime the anime to move.
* @param categories the selected categories.
*/
fun moveAnimeToCategories(anime: Anime, categories: List<Category>) {
val ac = categories.filter { it.id != 0 }.map { AnimeCategory.create(anime, it) }
db.setAnimeCategories(ac, listOf(anime))
}
/**
* Move the given anime to the category.
*
* @param anime the anime to move.
* @param category the selected category, or null for default category.
*/
fun moveAnimeToCategory(anime: Anime, category: Category?) {
moveAnimeToCategories(anime, listOfNotNull(category))
}
/**
* Update cover with local file.
*
* @param anime the anime edited.
* @param context Context.
* @param data uri of the cover resource.
*/
fun editCover(anime: Anime, context: Context, data: Uri) {
Observable
.fromCallable {
context.contentResolver.openInputStream(data)?.use {
if (anime.isLocal()) {
LocalSource.updateCover(context, anime, it)
anime.updateCoverLastModified(db)
} else if (anime.favorite) {
coverCache.setCustomCoverToCache(anime, it)
anime.updateCoverLastModified(db)
}
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ -> view.onSetCoverSuccess() },
{ view, e -> view.onSetCoverError(e) }
)
}
fun deleteCustomCover(anime: Anime) {
Observable
.fromCallable {
coverCache.deleteCustomCover(anime)
anime.updateCoverLastModified(db)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ -> view.onSetCoverSuccess() },
{ view, e -> view.onSetCoverError(e) }
)
}
// Anime info - end
// Episodes list - start
private fun observeDownloads() {
observeDownloadsStatusSubscription?.let { remove(it) }
observeDownloadsStatusSubscription = downloadManager.queue.getStatusObservable()
.observeOn(Schedulers.io())
.onBackpressureLatest()
.filter { download -> download.anime.id == anime.id }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(
{ view, it ->
onDownloadStatusChange(it)
view.onEpisodeDownloadUpdate(it)
},
{ _, error ->
Timber.e(error)
}
)
observeDownloadsPageSubscription?.let { remove(it) }
observeDownloadsPageSubscription = downloadManager.queue.getProgressObservable()
.observeOn(Schedulers.io())
.onBackpressureLatest()
.filter { download -> download.anime.id == anime.id }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestAnimeCache(AnimeController::onEpisodeDownloadUpdate) { _, error ->
Timber.e(error)
}
}
/**
* Converts a episode from the database to an extended model, allowing to store new fields.
*/
private fun Episode.toModel(): EpisodeItem {
// Create the model object.
val model = EpisodeItem(this, anime)
// Find an active download for this episode.
val download = downloadManager.queue.find { it.episode.id == id }
if (download != null) {
// If there's an active download, assign it.
model.download = download
}
return model
}
/**
* Finds and assigns the list of downloaded episodes.
*
* @param episodes the list of episode from the database.
*/
private fun setDownloadedEpisodes(episodes: List<EpisodeItem>) {
episodes
.filter { downloadManager.isEpisodeDownloaded(it, anime) }
.forEach { it.status = AnimeDownload.State.DOWNLOADED }
}
/**
* Requests an updated list of episodes from the source.
*/
fun fetchEpisodesFromSource(manualFetch: Boolean = false) {
hasRequested = true
if (fetchEpisodesJob?.isActive == true) return
fetchEpisodesJob = presenterScope.launchIO {
try {
val episodes = source.getEpisodeList(anime.toAnimeInfo())
.map { it.toSEpisode() }
val (newEpisodes, _) = syncEpisodesWithSource(db, episodes, anime, source)
if (manualFetch) {
downloadNewEpisodes(newEpisodes)
}
withUIContext { view?.onFetchEpisodesDone() }
} catch (e: Throwable) {
withUIContext { view?.onFetchEpisodesError(e) }
}
}
}
/**
* Updates the UI after applying the filters.
*/
private fun refreshEpisodes() {
episodesRelay.call(episodes)
}
/**
* Applies the view filters to the list of episodes obtained from the database.
* @param episodes the list of episodes from the database
* @return an observable of the list of episodes filtered and sorted.
*/
private fun applyEpisodeFilters(episodes: List<EpisodeItem>): Observable<List<EpisodeItem>> {
var observable = Observable.from(episodes).subscribeOn(Schedulers.io())
val unreadFilter = onlyUnread()
if (unreadFilter == State.INCLUDE) {
observable = observable.filter { !it.read }
} else if (unreadFilter == State.EXCLUDE) {
observable = observable.filter { it.read }
}
val downloadedFilter = onlyDownloaded()
if (downloadedFilter == State.INCLUDE) {
observable = observable.filter { it.isDownloaded || it.anime.isLocal() }
} else if (downloadedFilter == State.EXCLUDE) {
observable = observable.filter { !it.isDownloaded && !it.anime.isLocal() }
}
val bookmarkedFilter = onlyBookmarked()
if (bookmarkedFilter == State.INCLUDE) {
observable = observable.filter { it.bookmark }
} else if (bookmarkedFilter == State.EXCLUDE) {
observable = observable.filter { !it.bookmark }
}
return observable.toSortedList(getEpisodeSort())
}
fun getEpisodeSort(): (Episode, Episode) -> Int {
return when (anime.sorting) {
Anime.SORTING_SOURCE -> when (sortDescending()) {
true -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) }
false -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) }
}
Anime.SORTING_NUMBER -> when (sortDescending()) {
true -> { c1, c2 -> c2.episode_number.compareTo(c1.episode_number) }
false -> { c1, c2 -> c1.episode_number.compareTo(c2.episode_number) }
}
Anime.SORTING_UPLOAD_DATE -> when (sortDescending()) {
true -> { c1, c2 -> c2.date_upload.compareTo(c1.date_upload) }
false -> { c1, c2 -> c1.date_upload.compareTo(c2.date_upload) }
}
else -> throw NotImplementedError("Unimplemented sorting method")
}
}
/**
* Called when a download for the active anime changes status.
* @param download the download whose status changed.
*/
private fun onDownloadStatusChange(download: AnimeDownload) {
// Assign the download to the model object.
if (download.status == AnimeDownload.State.QUEUE) {
episodes.find { it.id == download.episode.id }?.let {
if (it.download == null) {
it.download = download
}
}
}
// Force UI update if downloaded filter active and download finished.
if (onlyDownloaded() != State.IGNORE && download.status == AnimeDownload.State.DOWNLOADED) {
refreshEpisodes()
}
}
/**
* Returns the next unread episode or null if everything is read.
*/
fun getNextUnreadEpisode(): EpisodeItem? {
return episodes.sortedWith(getEpisodeSort()).findLast { !it.read }
}
/**
* Mark the selected episode list as read/unread.
* @param selectedEpisodes the list of selected episodes.
* @param read whether to mark episodes as read or unread.
*/
fun markEpisodesRead(selectedEpisodes: List<EpisodeItem>, read: Boolean) {
val episodes = selectedEpisodes.map { episode ->
episode.read = read
if (!read) {
episode.last_page_read = 0
}
episode
}
launchIO {
db.updateEpisodesProgress(episodes).executeAsBlocking()
if (preferences.removeAfterMarkedAsRead()) {
deleteEpisodes(episodes.filter { it.read })
}
}
}
/**
* Downloads the given list of episodes with the manager.
* @param episodes the list of episodes to download.
*/
fun downloadEpisodes(episodes: List<Episode>) {
downloadManager.downloadEpisodes(anime, episodes)
}
/**
* Bookmarks the given list of episodes.
* @param selectedEpisodes the list of episodes to bookmark.
*/
fun bookmarkEpisodes(selectedEpisodes: List<EpisodeItem>, bookmarked: Boolean) {
launchIO {
selectedEpisodes
.forEach {
it.bookmark = bookmarked
db.updateEpisodeProgress(it).executeAsBlocking()
}
}
}
/**
* Deletes the given list of episode.
* @param episodes the list of episodes to delete.
*/
fun deleteEpisodes(episodes: List<EpisodeItem>) {
launchIO {
try {
downloadManager.deleteEpisodes(episodes, anime, source).forEach {
if (it is EpisodeItem) {
it.status = AnimeDownload.State.NOT_DOWNLOADED
it.download = null
}
}
if (onlyDownloaded() != State.IGNORE) {
refreshEpisodes()
}
view?.onEpisodesDeleted(episodes)
} catch (e: Throwable) {
view?.onEpisodesDeletedError(e)
}
}
}
private fun downloadNewEpisodes(episodes: List<Episode>) {
if (episodes.isEmpty() || !anime.shouldDownloadNewChapters(db, preferences)) return
downloadEpisodes(episodes)
}
/**
* Reverses the sorting and requests an UI update.
*/
fun reverseSortOrder() {
anime.setEpisodeOrder(if (sortDescending()) Anime.SORT_ASC else Anime.SORT_DESC)
db.updateFlags(anime).executeAsBlocking()
refreshEpisodes()
}
/**
* Sets the read filter and requests an UI update.
* @param state whether to display only unread episodes or all episodes.
*/
fun setUnreadFilter(state: State) {
anime.readFilter = when (state) {
State.IGNORE -> Anime.SHOW_ALL
State.INCLUDE -> Anime.SHOW_UNREAD
State.EXCLUDE -> Anime.SHOW_READ
}
db.updateFlags(anime).executeAsBlocking()
refreshEpisodes()
}
/**
* Sets the download filter and requests an UI update.
* @param state whether to display only downloaded episodes or all episodes.
*/
fun setDownloadedFilter(state: State) {
anime.downloadedFilter = when (state) {
State.IGNORE -> Anime.SHOW_ALL
State.INCLUDE -> Anime.SHOW_DOWNLOADED
State.EXCLUDE -> Anime.SHOW_NOT_DOWNLOADED
}
db.updateFlags(anime).executeAsBlocking()
refreshEpisodes()
}
/**
* Sets the bookmark filter and requests an UI update.
* @param state whether to display only bookmarked episodes or all episodes.
*/
fun setBookmarkedFilter(state: State) {
anime.bookmarkedFilter = when (state) {
State.IGNORE -> Anime.SHOW_ALL
State.INCLUDE -> Anime.SHOW_BOOKMARKED
State.EXCLUDE -> Anime.SHOW_NOT_BOOKMARKED
}
db.updateFlags(anime).executeAsBlocking()
refreshEpisodes()
}
/**
* Sets the active display mode.
* @param mode the mode to set.
*/
fun setDisplayMode(mode: Int) {
anime.displayMode = mode
db.updateFlags(anime).executeAsBlocking()
}
/**
* Sets the sorting method and requests an UI update.
* @param sort the sorting mode.
*/
fun setSorting(sort: Int) {
anime.sorting = sort
db.updateFlags(anime).executeAsBlocking()
refreshEpisodes()
}
/**
* Whether downloaded only mode is enabled.
*/
fun forceDownloaded(): Boolean {
return anime.favorite && preferences.downloadedOnly().get()
}
/**
* Whether the display only downloaded filter is enabled.
*/
fun onlyDownloaded(): State {
if (forceDownloaded()) {
return State.INCLUDE
}
return when (anime.downloadedFilter) {
Anime.SHOW_DOWNLOADED -> State.INCLUDE
Anime.SHOW_NOT_DOWNLOADED -> State.EXCLUDE
else -> State.IGNORE
}
}
/**
* Whether the display only downloaded filter is enabled.
*/
fun onlyBookmarked(): State {
return when (anime.bookmarkedFilter) {
Anime.SHOW_BOOKMARKED -> State.INCLUDE
Anime.SHOW_NOT_BOOKMARKED -> State.EXCLUDE
else -> State.IGNORE
}
}
/**
* Whether the display only unread filter is enabled.
*/
fun onlyUnread(): State {
return when (anime.readFilter) {
Anime.SHOW_UNREAD -> State.INCLUDE
Anime.SHOW_READ -> State.EXCLUDE
else -> State.IGNORE
}
}
/**
* Whether the sorting method is descending or ascending.
*/
fun sortDescending(): Boolean {
return anime.sortDescending()
}
// Episodes list - end
// Track sheet - start
private fun fetchTrackers() {
trackSubscription?.let { remove(it) }
trackSubscription = db.getTracks(anime)
.asRxObservable()
.map { tracks ->
loggedServices.map { service ->
TrackItem(tracks.find { it.sync_id == service.id }, service)
}
}
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { _trackList = it }
.subscribeLatestCache(AnimeController::onNextTrackers)
}
fun refreshTrackers() {
refreshTrackersJob?.cancel()
refreshTrackersJob = launchIO {
supervisorScope {
try {
trackList
.filter { it.track != null }
.map {
async {
val track = it.service.refreshAnime(it.track!!)
db.insertTrack(track).executeAsBlocking()
}
}
.awaitAll()
withUIContext { view?.onTrackingRefreshDone() }
} catch (e: Throwable) {
withUIContext { view?.onTrackingRefreshError(e) }
}
}
}
}
fun trackingSearch(query: String, service: TrackService) {
searchTrackerJob?.cancel()
searchTrackerJob = launchIO {
try {
val results = service.search(query)
withUIContext { view?.onTrackingSearchResults(results) }
} catch (e: Throwable) {
withUIContext { view?.onTrackingSearchResultsError(e) }
}
}
}
fun registerTracking(item: AnimeTrack?, service: TrackService) {
if (item != null) {
item.anime_id = anime.id!!
launchIO {
try {
service.bindAnime(item)
db.insertTrack(item).executeAsBlocking()
} catch (e: Throwable) {
withUIContext { view?.applicationContext?.toast(e.message) }
}
}
} else {
unregisterTracking(service)
}
}
fun unregisterTracking(service: TrackService) {
db.deleteTrackForAnime(anime, service).executeAsBlocking()
}
private fun updateRemote(track: AnimeTrack, service: TrackService) {
launchIO {
try {
service.updateAnime(track)
db.insertTrack(track).executeAsBlocking()
withUIContext { view?.onTrackingRefreshDone() }
} catch (e: Throwable) {
withUIContext { view?.onTrackingRefreshError(e) }
// Restart on error to set old values
fetchTrackers()
}
}
}
fun setTrackerStatus(item: TrackItem, index: Int) {
val track = item.track!!
track.status = item.service.getStatusList()[index]
if (track.status == item.service.getCompletionStatus() && track.total_episodes != 0) {
track.last_episode_seen = track.total_episodes
}
updateRemote(track, item.service)
}
fun setTrackerScore(item: TrackItem, index: Int) {
val track = item.track!!
track.score = item.service.indexToScore(index)
updateRemote(track, item.service)
}
fun setTrackerLastEpisodeRead(item: TrackItem, episodeNumber: Int) {
val track = item.track!!
track.last_episode_seen = episodeNumber
if (track.total_episodes != 0 && track.last_episode_seen == track.total_episodes) {
track.status = item.service.getCompletionStatus()
}
updateRemote(track, item.service)
}
fun setTrackerStartDate(item: TrackItem, date: Long) {
val track = item.track!!
track.started_watching_date = date
updateRemote(track, item.service)
}
fun setTrackerFinishDate(item: TrackItem, date: Long) {
val track = item.track!!
track.finished_watching_date = date
updateRemote(track, item.service)
}
// Track sheet - end
}

View file

@ -0,0 +1,70 @@
package eu.kanade.tachiyomi.ui.anime.episode
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.drawable.DrawableCompat
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.AnimeEpisodesHeaderBinding
import eu.kanade.tachiyomi.ui.anime.AnimeController
import eu.kanade.tachiyomi.util.system.getResourceColor
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
class AnimeEpisodesHeaderAdapter(
private val controller: AnimeController
) :
RecyclerView.Adapter<AnimeEpisodesHeaderAdapter.HeaderViewHolder>() {
private var numEpisodes: Int? = null
private var hasActiveFilters: Boolean = false
private lateinit var binding: AnimeEpisodesHeaderBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
binding = AnimeEpisodesHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return HeaderViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) {
holder.bind()
}
fun setNumEpisodes(numEpisodes: Int) {
this.numEpisodes = numEpisodes
notifyDataSetChanged()
}
fun setHasActiveFilters(hasActiveFilters: Boolean) {
this.hasActiveFilters = hasActiveFilters
notifyDataSetChanged()
}
inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
binding.episodesLabel.text = if (numEpisodes == null) {
view.context.getString(R.string.chapters)
} else {
view.context.resources.getQuantityString(R.plurals.manga_num_chapters, numEpisodes!!, numEpisodes)
}
val filterColor = if (hasActiveFilters) {
view.context.getResourceColor(R.attr.colorFilterActive)
} else {
view.context.getResourceColor(R.attr.colorOnBackground)
}
DrawableCompat.setTint(binding.btnEpisodesFilter.drawable, filterColor)
merge(view.clicks(), binding.btnEpisodesFilter.clicks())
.onEach { controller.showSettingsSheet() }
.launchIn(controller.viewScope)
}
}
}

View file

@ -0,0 +1,29 @@
package eu.kanade.tachiyomi.ui.anime.episode
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class DeleteEpisodesDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : DeleteEpisodesDialog.Listener {
constructor(target: T) : this() {
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialDialog(activity!!)
.message(R.string.confirm_delete_chapters)
.positiveButton(android.R.string.ok) {
(targetController as? Listener)?.deleteEpisodes()
}
.negativeButton(android.R.string.cancel)
}
interface Listener {
fun deleteEpisodes()
}
}

View file

@ -0,0 +1,77 @@
package eu.kanade.tachiyomi.ui.anime.episode
import android.app.Dialog
import android.os.Bundle
import androidx.core.os.bundleOf
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.customview.customView
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.widget.DialogCustomDownloadView
/**
* Dialog used to let user select amount of episodes to download.
*/
class DownloadCustomEpisodesDialog<T> : DialogController
where T : Controller, T : DownloadCustomEpisodesDialog.Listener {
/**
* Maximum number of episodes to download in download chooser.
*/
private val maxEpisodes: Int
/**
* Initialize dialog.
* @param maxEpisodes maximal number of episodes that user can download.
*/
constructor(target: T, maxEpisodes: Int) : super(
// Add maximum number of episodes to download value to bundle.
bundleOf(KEY_ITEM_MAX to maxEpisodes)
) {
targetController = target
this.maxEpisodes = maxEpisodes
}
/**
* Restore dialog.
* @param bundle bundle containing data from state restore.
*/
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
// Get maximum episodes to download from bundle
val maxEpisodes = bundle.getInt(KEY_ITEM_MAX, 0)
this.maxEpisodes = maxEpisodes
}
/**
* Called when dialog is being created.
*/
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val activity = activity!!
// Initialize view that lets user select number of episodes to download.
val view = DialogCustomDownloadView(activity).apply {
setMinMax(0, maxEpisodes)
}
// Build dialog.
// when positive dialog is pressed call custom listener.
return MaterialDialog(activity)
.title(R.string.custom_download)
.customView(view = view, scrollable = true)
.positiveButton(android.R.string.ok) {
(targetController as? Listener)?.downloadCustomEpisodes(view.amount)
}
.negativeButton(android.R.string.cancel)
}
interface Listener {
fun downloadCustomEpisodes(amount: Int)
}
private companion object {
// Key to retrieve max episodes from bundle on process death.
const val KEY_ITEM_MAX = "DownloadCustomEpisodesDialog.int.maxEpisodes"
}
}

View file

@ -0,0 +1,70 @@
package eu.kanade.tachiyomi.ui.anime.episode
import android.animation.ObjectAnimator
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.FrameLayout
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.data.download.model.AnimeDownload
import eu.kanade.tachiyomi.databinding.EpisodeDownloadViewBinding
class EpisodeDownloadView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
FrameLayout(context, attrs) {
private val binding: EpisodeDownloadViewBinding
private var state = AnimeDownload.State.NOT_DOWNLOADED
private var progress = 0
private var downloadIconAnimator: ObjectAnimator? = null
private var isAnimating = false
init {
binding = EpisodeDownloadViewBinding.inflate(LayoutInflater.from(context), this, false)
addView(binding.root)
}
fun setState(state: AnimeDownload.State, progress: Int = 0) {
val isDirty = this.state.value != state.value || this.progress != progress
this.state = state
this.progress = progress
if (isDirty) {
updateLayout()
}
}
private fun updateLayout() {
binding.downloadIconBorder.isVisible = state == AnimeDownload.State.NOT_DOWNLOADED
binding.downloadIcon.isVisible = state == AnimeDownload.State.NOT_DOWNLOADED || state == AnimeDownload.State.DOWNLOADING
if (state == AnimeDownload.State.DOWNLOADING) {
if (!isAnimating) {
downloadIconAnimator =
ObjectAnimator.ofFloat(binding.downloadIcon, "alpha", 1f, 0f).apply {
duration = 1000
repeatCount = ObjectAnimator.INFINITE
repeatMode = ObjectAnimator.REVERSE
}
downloadIconAnimator?.start()
isAnimating = true
}
} else {
downloadIconAnimator?.cancel()
binding.downloadIcon.alpha = 1f
isAnimating = false
}
binding.downloadQueued.isVisible = state == AnimeDownload.State.QUEUE
binding.downloadProgress.isVisible = state == AnimeDownload.State.DOWNLOADING ||
(state == AnimeDownload.State.QUEUE && progress > 0)
binding.downloadProgress.progress = progress
binding.downloadedIcon.isVisible = state == AnimeDownload.State.DOWNLOADED
binding.errorIcon.isVisible = state == AnimeDownload.State.ERROR
}
}

View file

@ -0,0 +1,82 @@
package eu.kanade.tachiyomi.ui.anime.episode
import android.text.SpannableStringBuilder
import android.view.View
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.databinding.EpisodesItemBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.anime.episode.base.BaseEpisodeHolder
import java.util.Date
class EpisodeHolder(
view: View,
private val adapter: EpisodesAdapter
) : BaseEpisodeHolder(view, adapter) {
private val binding = EpisodesItemBinding.bind(view)
init {
binding.animedownload.setOnClickListener {
onAnimeDownloadClick(it, bindingAdapterPosition)
}
}
fun bind(item: EpisodeItem, anime: Anime) {
val episode = item.episode
binding.episodeTitle.text = when (anime.displayMode) {
Anime.DISPLAY_NUMBER -> {
val number = adapter.decimalFormat.format(episode.episode_number.toDouble())
itemView.context.getString(R.string.display_mode_chapter, number)
}
else -> episode.name
}
// Set correct text color
val episodeTitleColor = when {
episode.read -> adapter.readColor
episode.bookmark -> adapter.bookmarkedColor
else -> adapter.unreadColor
}
binding.episodeTitle.setTextColor(episodeTitleColor)
val episodeDescriptionColor = when {
episode.read -> adapter.readColor
episode.bookmark -> adapter.bookmarkedColor
else -> adapter.unreadColorSecondary
}
binding.episodeDescription.setTextColor(episodeDescriptionColor)
binding.bookmarkIcon.isVisible = episode.bookmark
val descriptions = mutableListOf<CharSequence>()
if (episode.date_upload > 0) {
descriptions.add(adapter.dateFormat.format(Date(episode.date_upload)))
}
if (!episode.read && episode.last_page_read > 0) {
val lastPageRead = buildSpannedString {
color(adapter.readColor) {
append(itemView.context.getString(R.string.chapter_progress, episode.last_page_read + 1))
}
}
descriptions.add(lastPageRead)
}
if (!episode.scanlator.isNullOrBlank()) {
descriptions.add(episode.scanlator!!)
}
if (descriptions.isNotEmpty()) {
binding.episodeDescription.text = descriptions.joinTo(SpannableStringBuilder(), "")
} else {
binding.episodeDescription.text = ""
}
binding.animedownload.isVisible = item.anime.source != LocalSource.ID
binding.animedownload.setState(item.status, item.progress)
}
}

View file

@ -0,0 +1,33 @@
package eu.kanade.tachiyomi.ui.anime.episode
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Episode
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.ui.anime.episode.base.BaseEpisodeItem
class EpisodeItem(episode: Episode, val anime: Anime) :
BaseEpisodeItem<EpisodeHolder, AbstractHeaderItem<FlexibleViewHolder>>(episode) {
override fun getLayoutRes(): Int {
return R.layout.episodes_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): EpisodeHolder {
return EpisodeHolder(view, adapter as EpisodesAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: EpisodeHolder,
position: Int,
payloads: List<Any?>?
) {
holder.bind(this, anime)
}
}

View file

@ -0,0 +1,45 @@
package eu.kanade.tachiyomi.ui.anime.episode
import android.content.Context
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.anime.AnimeController
import eu.kanade.tachiyomi.ui.anime.episode.base.BaseEpisodesAdapter
import eu.kanade.tachiyomi.util.system.getResourceColor
import uy.kohesive.injekt.injectLazy
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
class EpisodesAdapter(
controller: AnimeController,
context: Context
) : BaseEpisodesAdapter<EpisodeItem>(controller) {
private val preferences: PreferencesHelper by injectLazy()
var items: List<EpisodeItem> = emptyList()
val readColor = context.getResourceColor(R.attr.colorOnSurface, 0.38f)
val unreadColor = context.getResourceColor(R.attr.colorOnSurface)
val unreadColorSecondary = context.getResourceColor(android.R.attr.textColorSecondary)
val bookmarkedColor = context.getResourceColor(R.attr.colorAccent)
val decimalFormat = DecimalFormat(
"#.###",
DecimalFormatSymbols()
.apply { decimalSeparator = '.' }
)
val dateFormat: DateFormat = preferences.dateFormat()
override fun updateDataSet(items: List<EpisodeItem>?) {
this.items = items ?: emptyList()
super.updateDataSet(items)
}
fun indexOf(item: EpisodeItem): Int {
return items.indexOf(item)
}
}

View file

@ -0,0 +1,269 @@
package eu.kanade.tachiyomi.ui.anime.episode
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.core.view.isVisible
import com.bluelinelabs.conductor.Router
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.ui.anime.AnimePresenter
import eu.kanade.tachiyomi.util.view.popupMenu
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State
import eu.kanade.tachiyomi.widget.sheet.TabbedBottomSheetDialog
class EpisodesSettingsSheet(
private val router: Router,
private val presenter: AnimePresenter,
onGroupClickListener: (ExtendedNavigationView.Group) -> Unit
) : TabbedBottomSheetDialog(router.activity!!) {
val filters: Filter
private val sort: Sort
private val display: Display
init {
filters = Filter(router.activity!!)
filters.onGroupClicked = onGroupClickListener
sort = Sort(router.activity!!)
sort.onGroupClicked = onGroupClickListener
display = Display(router.activity!!)
display.onGroupClicked = onGroupClickListener
binding.menu.isVisible = true
binding.menu.setOnClickListener { it.post { showPopupMenu(it) } }
}
override fun getTabViews(): List<View> = listOf(
filters,
sort,
display
)
override fun getTabTitles(): List<Int> = listOf(
R.string.action_filter,
R.string.action_sort,
R.string.action_display
)
private fun showPopupMenu(view: View) {
view.popupMenu(
R.menu.default_chapter_filter,
{
},
{
when (this.itemId) {
R.id.set_as_default -> {
SetEpisodeSettingsDialog(presenter.anime).showDialog(router)
true
}
else -> true
}
}
)
}
/**
* Filters group (unread, downloaded, ...).
*/
inner class Filter @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
Settings(context, attrs) {
private val filterGroup = FilterGroup()
init {
setGroups(listOf(filterGroup))
}
/**
* Returns true if there's at least one filter from [FilterGroup] active.
*/
fun hasActiveFilters(): Boolean {
return filterGroup.items.any { it.state != State.IGNORE.value }
}
inner class FilterGroup : Group {
private val downloaded = Item.TriStateGroup(R.string.action_filter_downloaded, this)
private val unread = Item.TriStateGroup(R.string.action_filter_unread, this)
private val bookmarked = Item.TriStateGroup(R.string.action_filter_bookmarked, this)
override val header = null
override val items = listOf(downloaded, unread, bookmarked)
override val footer = null
override fun initModels() {
if (presenter.forceDownloaded()) {
downloaded.state = State.INCLUDE.value
downloaded.enabled = false
} else {
downloaded.state = presenter.onlyDownloaded().value
}
unread.state = presenter.onlyUnread().value
bookmarked.state = presenter.onlyBookmarked().value
}
override fun onItemClicked(item: Item) {
item as Item.TriStateGroup
val newState = when (item.state) {
State.IGNORE.value -> State.INCLUDE
State.INCLUDE.value -> State.EXCLUDE
State.EXCLUDE.value -> State.IGNORE
else -> throw Exception("Unknown State")
}
item.state = newState.value
when (item) {
downloaded -> presenter.setDownloadedFilter(newState)
unread -> presenter.setUnreadFilter(newState)
bookmarked -> presenter.setBookmarkedFilter(newState)
}
initModels()
item.group.items.forEach { adapter.notifyItemChanged(it) }
}
}
}
/**
* Sorting group (alphabetically, by last read, ...) and ascending or descending.
*/
inner class Sort @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
Settings(context, attrs) {
init {
setGroups(listOf(SortGroup()))
}
inner class SortGroup : Group {
private val source = Item.MultiSort(R.string.sort_by_source, this)
private val episodeNum = Item.MultiSort(R.string.sort_by_number, this)
private val uploadDate = Item.MultiSort(R.string.sort_by_upload_date, this)
override val header = null
override val items = listOf(source, uploadDate, episodeNum)
override val footer = null
override fun initModels() {
val sorting = presenter.anime.sorting
val order = if (presenter.anime.sortDescending()) {
Item.MultiSort.SORT_DESC
} else {
Item.MultiSort.SORT_ASC
}
source.state =
if (sorting == Anime.SORTING_SOURCE) order else Item.MultiSort.SORT_NONE
episodeNum.state =
if (sorting == Anime.SORTING_NUMBER) order else Item.MultiSort.SORT_NONE
uploadDate.state =
if (sorting == Anime.SORTING_UPLOAD_DATE) order else Item.MultiSort.SORT_NONE
}
override fun onItemClicked(item: Item) {
item as Item.MultiStateGroup
val prevState = item.state
item.group.items.forEach {
(it as Item.MultiStateGroup).state =
Item.MultiSort.SORT_NONE
}
item.state = when (prevState) {
Item.MultiSort.SORT_NONE -> Item.MultiSort.SORT_ASC
Item.MultiSort.SORT_ASC -> Item.MultiSort.SORT_DESC
Item.MultiSort.SORT_DESC -> Item.MultiSort.SORT_ASC
else -> throw Exception("Unknown state")
}
when (item) {
source -> presenter.setSorting(Anime.SORTING_SOURCE)
episodeNum -> presenter.setSorting(Anime.SORTING_NUMBER)
uploadDate -> presenter.setSorting(Anime.SORTING_UPLOAD_DATE)
else -> throw Exception("Unknown sorting")
}
presenter.reverseSortOrder()
item.group.items.forEach { adapter.notifyItemChanged(it) }
}
}
}
/**
* Display group, to show the library as a list or a grid.
*/
inner class Display @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
Settings(context, attrs) {
init {
setGroups(listOf(DisplayGroup()))
}
inner class DisplayGroup : Group {
private val displayTitle = Item.Radio(R.string.show_title, this)
private val displayEpisodeNum = Item.Radio(R.string.show_chapter_number, this)
override val header = null
override val items = listOf(displayTitle, displayEpisodeNum)
override val footer = null
override fun initModels() {
val mode = presenter.anime.displayMode
displayTitle.checked = mode == Anime.DISPLAY_NAME
displayEpisodeNum.checked = mode == Anime.DISPLAY_NUMBER
}
override fun onItemClicked(item: Item) {
item as Item.Radio
if (item.checked) return
item.group.items.forEach { (it as Item.Radio).checked = false }
item.checked = true
when (item) {
displayTitle -> presenter.setDisplayMode(Anime.DISPLAY_NAME)
displayEpisodeNum -> presenter.setDisplayMode(Anime.DISPLAY_NUMBER)
else -> throw NotImplementedError("Unknown display mode")
}
item.group.items.forEach { adapter.notifyItemChanged(it) }
}
}
}
open inner class Settings(context: Context, attrs: AttributeSet?) :
ExtendedNavigationView(context, attrs) {
lateinit var adapter: Adapter
/**
* Click listener to notify the parent fragment when an item from a group is clicked.
*/
var onGroupClicked: (Group) -> Unit = {}
fun setGroups(groups: List<Group>) {
adapter = Adapter(groups.map { it.createItems() }.flatten())
recycler.adapter = adapter
groups.forEach { it.initModels() }
addView(recycler)
}
/**
* Adapter of the recycler view.
*/
inner class Adapter(items: List<Item>) : ExtendedNavigationView.Adapter(items) {
override fun onItemClicked(item: Item) {
if (item is GroupedItem) {
item.group.onItemClicked(item)
onGroupClicked(item.group)
}
}
}
}
}

View file

@ -0,0 +1,47 @@
package eu.kanade.tachiyomi.ui.anime.episode
import android.app.Dialog
import android.os.Bundle
import androidx.core.os.bundleOf
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.customview.customView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.util.episode.EpisodeSettingsHelper
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.widget.DialogCheckboxView
class SetEpisodeSettingsDialog(bundle: Bundle? = null) : DialogController(bundle) {
constructor(anime: Anime) : this(
bundleOf(MANGA_KEY to anime)
)
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val view = DialogCheckboxView(activity!!).apply {
setDescription(R.string.confirm_set_chapter_settings)
setOptionDescription(R.string.also_set_chapter_settings_for_library)
}
return MaterialDialog(activity!!)
.title(R.string.chapter_settings)
.customView(
view = view,
horizontalPadding = true
)
.positiveButton(android.R.string.ok) {
EpisodeSettingsHelper.setGlobalSettings(args.getSerializable(MANGA_KEY)!! as Anime)
if (view.isChecked()) {
EpisodeSettingsHelper.updateAllAnimesWithGlobalDefaults()
}
activity?.toast(activity!!.getString(R.string.chapter_settings_updated))
}
.negativeButton(android.R.string.cancel)
}
private companion object {
const val MANGA_KEY = "anime"
}
}

View file

@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.ui.anime.episode.base
import android.view.View
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.AnimeDownload
import eu.kanade.tachiyomi.util.view.popupMenu
open class BaseEpisodeHolder(
view: View,
private val adapter: BaseEpisodesAdapter<*>
) : FlexibleViewHolder(view, adapter) {
fun onAnimeDownloadClick(view: View, position: Int) {
val item = adapter.getItem(position) as? BaseEpisodeItem<*, *> ?: return
when (item.status) {
AnimeDownload.State.NOT_DOWNLOADED, AnimeDownload.State.ERROR -> {
adapter.clickListener.downloadEpisode(position)
}
else -> {
view.popupMenu(
R.menu.chapter_download,
initMenu = {
// AnimeDownload.State.DOWNLOADED
findItem(R.id.delete_download).isVisible = item.status == AnimeDownload.State.DOWNLOADED
// AnimeDownload.State.DOWNLOADING, AnimeDownload.State.QUEUE
findItem(R.id.cancel_download).isVisible = item.status != AnimeDownload.State.DOWNLOADED
},
onMenuItemClick = {
adapter.clickListener.deleteEpisode(position)
true
}
)
}
}
}
}

View file

@ -0,0 +1,47 @@
package eu.kanade.tachiyomi.ui.anime.episode.base
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
import eu.davidea.flexibleadapter.items.AbstractSectionableItem
import eu.kanade.tachiyomi.data.database.models.Episode
import eu.kanade.tachiyomi.data.download.model.AnimeDownload
import eu.kanade.tachiyomi.source.model.Page
abstract class BaseEpisodeItem<T : BaseEpisodeHolder, H : AbstractHeaderItem<*>>(
val episode: Episode,
header: H? = null
) :
AbstractSectionableItem<T, H?>(header),
Episode by episode {
private var _status: AnimeDownload.State = AnimeDownload.State.NOT_DOWNLOADED
var status: AnimeDownload.State
get() = download?.status ?: _status
set(value) {
_status = value
}
val progress: Int
get() {
val pages = download?.pages ?: return 0
return pages.map(Page::progress).average().toInt()
}
@Transient
var download: AnimeDownload? = null
val isDownloaded: Boolean
get() = status == AnimeDownload.State.DOWNLOADED
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is BaseEpisodeItem<*, *>) {
return episode.id!! == other.episode.id!!
}
return false
}
override fun hashCode(): Int {
return episode.id!!.hashCode()
}
}

View file

@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.ui.anime.episode.base
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
abstract class BaseEpisodesAdapter<T : IFlexible<*>>(
controller: OnEpisodeClickListener
) : FlexibleAdapter<T>(null, controller, true) {
/**
* Listener for browse item clicks.
*/
val clickListener: OnEpisodeClickListener = controller
/**
* Listener which should be called when user clicks the download icons.
*/
interface OnEpisodeClickListener {
fun downloadEpisode(position: Int)
fun deleteEpisode(position: Int)
}
}

View file

@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
import kotlin.math.min
/**
* A custom ImageView for holding a manga cover with:
* - width: min(maxWidth attr, 33% of parent width)
* - height: 2:3 width:height ratio
*
* Should be defined with a width of match_parent.
*/
class AnimeCoverImageView(context: Context, attrs: AttributeSet?) : AppCompatImageView(context, attrs) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = min(maxWidth, measuredWidth / 3)
val height = width / 2 * 3
setMeasuredDimension(width, height)
}
}

View file

@ -0,0 +1,358 @@
package eu.kanade.tachiyomi.ui.anime.info
import android.graphics.PorterDuff
import android.os.Build
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.AnimeThumbnail
import eu.kanade.tachiyomi.data.glide.toAnimeThumbnail
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.databinding.AnimeInfoHeaderBinding
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SAnime
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.anime.AnimeController
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.view.setChips
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
import reactivecircus.flowbinding.android.view.longClicks
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
class AnimeInfoHeaderAdapter(
private val controller: AnimeController,
private val fromSource: Boolean
) :
RecyclerView.Adapter<AnimeInfoHeaderAdapter.HeaderViewHolder>() {
private val trackManager: TrackManager by injectLazy()
private var anime: Anime = controller.presenter.anime
private var source: Source = controller.presenter.source
private var trackCount: Int = 0
private lateinit var binding: AnimeInfoHeaderBinding
private var initialLoad: Boolean = true
private var currentAnimeThumbnail: AnimeThumbnail? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
binding = AnimeInfoHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return HeaderViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) {
holder.bind()
}
/**
* Update the view with anime information.
*
* @param anime anime object containing information about anime.
* @param source the source of the anime.
*/
fun update(anime: Anime, source: Source) {
this.anime = anime
this.source = source
notifyDataSetChanged()
}
fun setTrackingCount(trackCount: Int) {
this.trackCount = trackCount
notifyDataSetChanged()
}
inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
// For rounded corners
binding.animeCover.clipToOutline = true
binding.btnFavorite.clicks()
.onEach { controller.onFavoriteClick() }
.launchIn(controller.viewScope)
if (controller.presenter.anime.favorite && controller.presenter.getCategories().isNotEmpty()) {
binding.btnFavorite.longClicks()
.onEach { controller.onCategoriesClick() }
.launchIn(controller.viewScope)
}
with(binding.btnTracking) {
if (trackManager.hasLoggedServices()) {
isVisible = true
if (trackCount > 0) {
setIconResource(R.drawable.ic_done_24dp)
text = view.context.resources.getQuantityString(
R.plurals.num_trackers,
trackCount,
trackCount
)
isActivated = true
} else {
setIconResource(R.drawable.ic_sync_24dp)
text = view.context.getString(R.string.manga_tracking_tab)
isActivated = false
}
clicks()
.onEach { controller.onTrackingClick() }
.launchIn(controller.viewScope)
} else {
isVisible = false
}
}
if (controller.presenter.source is HttpSource) {
binding.btnWebview.isVisible = true
binding.btnWebview.clicks()
.onEach { controller.openAnimeInWebView() }
.launchIn(controller.viewScope)
}
binding.animeFullTitle.longClicks()
.onEach {
controller.activity?.copyToClipboard(
view.context.getString(R.string.title),
binding.animeFullTitle.text.toString()
)
}
.launchIn(controller.viewScope)
binding.animeFullTitle.clicks()
.onEach {
controller.performGlobalSearch(binding.animeFullTitle.text.toString())
}
.launchIn(controller.viewScope)
binding.animeAuthor.longClicks()
.onEach {
controller.activity?.copyToClipboard(
binding.animeAuthor.text.toString(),
binding.animeAuthor.text.toString()
)
}
.launchIn(controller.viewScope)
binding.animeAuthor.clicks()
.onEach {
controller.performGlobalSearch(binding.animeAuthor.text.toString())
}
.launchIn(controller.viewScope)
binding.animeArtist.longClicks()
.onEach {
controller.activity?.copyToClipboard(
binding.animeArtist.text.toString(),
binding.animeArtist.text.toString()
)
}
.launchIn(controller.viewScope)
binding.animeArtist.clicks()
.onEach {
controller.performGlobalSearch(binding.animeArtist.text.toString())
}
.launchIn(controller.viewScope)
binding.animeSummaryText.longClicks()
.onEach {
controller.activity?.copyToClipboard(
view.context.getString(R.string.description),
binding.animeSummaryText.text.toString()
)
}
.launchIn(controller.viewScope)
binding.animeCover.longClicks()
.onEach {
controller.activity?.copyToClipboard(
view.context.getString(R.string.title),
controller.presenter.anime.title
)
}
.launchIn(controller.viewScope)
setAnimeInfo(anime, source)
}
/**
* Update the view with anime information.
*
* @param anime anime object containing information about anime.
* @param source the source of the anime.
*/
private fun setAnimeInfo(anime: Anime, source: Source?) {
// Update full title TextView.
binding.animeFullTitle.text = if (anime.title.isBlank()) {
view.context.getString(R.string.unknown)
} else {
anime.title
}
// Update author TextView.
binding.animeAuthor.text = if (anime.author.isNullOrBlank()) {
view.context.getString(R.string.unknown_author)
} else {
anime.author
}
// Update artist TextView.
val hasArtist = !anime.artist.isNullOrBlank() && anime.artist != anime.author
binding.animeArtist.isVisible = hasArtist
if (hasArtist) {
binding.animeArtist.text = anime.artist
}
// If anime source is known update source TextView.
val animeSource = source?.toString()
with(binding.animeSource) {
if (animeSource != null) {
text = animeSource
setOnClickListener {
val sourceManager = Injekt.get<SourceManager>()
controller.performSearch(sourceManager.getOrStub(source.id).name)
}
} else {
text = view.context.getString(R.string.unknown)
}
}
// Update status TextView.
binding.animeStatus.setText(
when (anime.status) {
SAnime.ONGOING -> R.string.ongoing
SAnime.COMPLETED -> R.string.completed
SAnime.LICENSED -> R.string.licensed
else -> R.string.unknown_status
}
)
// Set the favorite drawable to the correct one.
setFavoriteButtonState(anime.favorite)
// Set cover if changed.
val animeThumbnail = anime.toAnimeThumbnail()
if (animeThumbnail != currentAnimeThumbnail) {
currentAnimeThumbnail = animeThumbnail
listOf(binding.animeCover, binding.backdrop)
.forEach {
GlideApp.with(view.context)
.load(animeThumbnail)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(it)
}
}
// Anime info section
val hasInfoContent = !anime.description.isNullOrBlank() || !anime.genre.isNullOrBlank()
showAnimeInfo(hasInfoContent)
if (hasInfoContent) {
// Update description TextView.
binding.animeSummaryText.text = if (anime.description.isNullOrBlank()) {
view.context.getString(R.string.unknown)
} else {
anime.description
}
// Update genres list
if (!anime.genre.isNullOrBlank()) {
binding.animeGenresTagsCompactChips.setChips(
anime.getGenres(),
controller::performSearch
)
binding.animeGenresTagsFullChips.setChips(
anime.getGenres(),
controller::performSearch
)
} else {
binding.animeGenresTagsCompactChips.isVisible = false
binding.animeGenresTagsFullChips.isVisible = false
}
// Handle showing more or less info
merge(
binding.animeSummarySection.clicks(),
binding.animeSummaryText.clicks(),
binding.animeInfoToggleMore.clicks(),
binding.animeInfoToggleLess.clicks()
)
.onEach { toggleAnimeInfo() }
.launchIn(controller.viewScope)
// Expand anime info if navigated from source listing
if (initialLoad && fromSource) {
toggleAnimeInfo()
initialLoad = false
}
}
// backgroundTint attribute doesn't work properly on Android 5
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP) {
listOf(binding.backdropOverlay, binding.animeInfoToggleMoreScrim)
.forEach {
@Suppress("DEPRECATION")
it.background.setColorFilter(
view.context.getResourceColor(android.R.attr.colorBackground),
PorterDuff.Mode.SRC_ATOP
)
}
}
}
private fun showAnimeInfo(visible: Boolean) {
binding.animeSummarySection.isVisible = visible
}
private fun toggleAnimeInfo() {
val isCurrentlyExpanded = binding.animeSummaryText.maxLines != 2
binding.animeInfoToggleMoreScrim.isVisible = isCurrentlyExpanded
binding.animeInfoToggleMore.isVisible = isCurrentlyExpanded
binding.animeInfoToggleLess.isVisible = !isCurrentlyExpanded
binding.animeSummaryText.maxLines = if (isCurrentlyExpanded) {
2
} else {
Int.MAX_VALUE
}
binding.animeGenresTagsCompact.isVisible = isCurrentlyExpanded
binding.animeGenresTagsFullChips.isVisible = !isCurrentlyExpanded
}
/**
* Update favorite button with correct drawable and text.
*
* @param isFavorite determines if anime is favorite or not.
*/
private fun setFavoriteButtonState(isFavorite: Boolean) {
// Set the Favorite drawable to the correct one.
// Border drawable if false, filled drawable if true.
binding.btnFavorite.apply {
setIconResource(if (isFavorite) R.drawable.ic_favorite_24dp else R.drawable.ic_favorite_border_24dp)
text =
context.getString(if (isFavorite) R.string.in_library else R.string.add_to_library)
isActivated = isFavorite
}
}
}
}

View file

@ -0,0 +1,79 @@
package eu.kanade.tachiyomi.ui.anime.track
import android.app.Dialog
import android.os.Bundle
import android.widget.NumberPicker
import androidx.core.os.bundleOf
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.customview.customView
import com.afollestad.materialdialogs.customview.getCustomView
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.AnimeTrack
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class SetTrackEpisodesDialog<T> : DialogController
where T : Controller {
private val item: TrackItem
private lateinit var listener: Listener
constructor(target: T, listener: Listener, item: TrackItem) : super(
bundleOf(KEY_ITEM_TRACK to item.track)
) {
targetController = target
this.listener = listener
this.item = item
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
val track = bundle.getSerializable(KEY_ITEM_TRACK) as AnimeTrack
val service = Injekt.get<TrackManager>().getService(track.sync_id)!!
item = TrackItem(track, service)
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val item = item
val dialog = MaterialDialog(activity!!)
.title(R.string.chapters)
.customView(R.layout.track_chapters_dialog, dialogWrapContent = false)
.positiveButton(android.R.string.ok) { dialog ->
val view = dialog.getCustomView()
// Remove focus to update selected number
val np: NumberPicker = view.findViewById(R.id.chapters_picker)
np.clearFocus()
listener.setEpisodesRead(item, np.value)
}
.negativeButton(android.R.string.cancel)
val view = dialog.getCustomView()
val np: NumberPicker = view.findViewById(R.id.chapters_picker)
// Set initial value
np.value = item.track?.last_episode_seen ?: 0
// Enforce maximum value if tracker has total number of chapters set
if (item.track != null && item.track.total_episodes > 0) {
np.maxValue = item.track.total_episodes
}
// Don't allow to go from 0 to 9999
np.wrapSelectorWheel = false
return dialog
}
interface Listener {
fun setEpisodesRead(item: TrackItem, chaptersRead: Int)
}
private companion object {
const val KEY_ITEM_TRACK = "SetTrackChaptersDialog.item.track"
}
}

View file

@ -0,0 +1,79 @@
package eu.kanade.tachiyomi.ui.anime.track
import android.app.Dialog
import android.os.Bundle
import android.widget.NumberPicker
import androidx.core.os.bundleOf
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.customview.customView
import com.afollestad.materialdialogs.customview.getCustomView
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.AnimeTrack
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class SetTrackScoreDialog<T> : DialogController
where T : Controller {
private val item: TrackItem
private lateinit var listener: Listener
constructor(target: T, listener: Listener, item: TrackItem) : super(
bundleOf(KEY_ITEM_TRACK to item.track)
) {
targetController = target
this.listener = listener
this.item = item
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
val track = bundle.getSerializable(KEY_ITEM_TRACK) as AnimeTrack
val service = Injekt.get<TrackManager>().getService(track.sync_id)!!
item = TrackItem(track, service)
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val item = item
val dialog = MaterialDialog(activity!!)
.title(R.string.score)
.customView(R.layout.track_score_dialog, dialogWrapContent = false)
.positiveButton(android.R.string.ok) { dialog ->
val view = dialog.getCustomView()
// Remove focus to update selected number
val np: NumberPicker = view.findViewById(R.id.score_picker)
np.clearFocus()
listener.setScore(item, np.value)
}
.negativeButton(android.R.string.cancel)
val view = dialog.getCustomView()
val np: NumberPicker = view.findViewById(R.id.score_picker)
val scores = item.service.getScoreList().toTypedArray()
np.maxValue = scores.size - 1
np.displayedValues = scores
// Set initial value
val displayedScore = item.service.displayScore(item.track!!)
if (displayedScore != "-") {
val index = scores.indexOf(displayedScore)
np.value = if (index != -1) index else 0
}
return dialog
}
interface Listener {
fun setScore(item: TrackItem, score: Int)
}
private companion object {
const val KEY_ITEM_TRACK = "SetTrackScoreDialog.item.track"
}
}

View file

@ -0,0 +1,64 @@
package eu.kanade.tachiyomi.ui.anime.track
import android.app.Dialog
import android.os.Bundle
import androidx.core.os.bundleOf
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItemsSingleChoice
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.AnimeTrack
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class SetTrackStatusDialog<T> : DialogController
where T : Controller {
private val item: TrackItem
private lateinit var listener: Listener
constructor(target: T, listener: Listener, item: TrackItem) : super(
bundleOf(KEY_ITEM_TRACK to item.track)
) {
targetController = target
this.listener = listener
this.item = item
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
val track = bundle.getSerializable(KEY_ITEM_TRACK) as AnimeTrack
val service = Injekt.get<TrackManager>().getService(track.sync_id)!!
item = TrackItem(track, service)
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val item = item
val statusList = item.service.getStatusList()
val statusString = statusList.map { item.service.getStatus(it) }
val selectedIndex = statusList.indexOf(item.track?.status)
return MaterialDialog(activity!!)
.title(R.string.status)
.negativeButton(android.R.string.cancel)
.listItemsSingleChoice(
items = statusString,
initialSelection = selectedIndex,
waitForPositiveButton = false
) { dialog, position, _ ->
listener.setStatus(item, position)
dialog.dismiss()
}
}
interface Listener {
fun setStatus(item: TrackItem, selection: Int)
}
private companion object {
const val KEY_ITEM_TRACK = "SetTrackStatusDialog.item.track"
}
}

View file

@ -0,0 +1,86 @@
package eu.kanade.tachiyomi.ui.anime.track
import android.app.Dialog
import android.os.Bundle
import androidx.core.os.bundleOf
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.datetime.datePicker
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Calendar
class SetTrackWatchingDatesDialog<T> : DialogController
where T : Controller {
private val item: TrackItem
private val dateToUpdate: ReadingDate
private lateinit var listener: Listener
constructor(target: T, listener: Listener, dateToUpdate: ReadingDate, item: TrackItem) : super(
bundleOf(KEY_ITEM_TRACK to item.track)
) {
targetController = target
this.listener = listener
this.item = item
this.dateToUpdate = dateToUpdate
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track
val service = Injekt.get<TrackManager>().getService(track.sync_id)!!
item = TrackItem(track, service)
dateToUpdate = ReadingDate.Start
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialDialog(activity!!)
.title(
when (dateToUpdate) {
ReadingDate.Start -> R.string.track_started_reading_date
ReadingDate.Finish -> R.string.track_finished_reading_date
}
)
.datePicker(currentDate = getCurrentDate()) { _, date ->
listener?.setReadingDate(item, dateToUpdate, date.timeInMillis)
}
.neutralButton(R.string.action_remove) {
listener?.setReadingDate(item, dateToUpdate, 0L)
}
}
private fun getCurrentDate(): Calendar {
// Today if no date is set, otherwise the already set date
return Calendar.getInstance().apply {
item.track?.let {
val date = when (dateToUpdate) {
ReadingDate.Start -> it.started_reading_date
ReadingDate.Finish -> it.finished_reading_date
}
if (date != 0L) {
timeInMillis = date
}
}
}
}
interface Listener {
fun setReadingDate(item: TrackItem, type: ReadingDate, date: Long)
}
enum class ReadingDate {
Start,
Finish
}
companion object {
private const val KEY_ITEM_TRACK = "SetTrackReadingDatesDialog.item.track"
}
}

View file

@ -0,0 +1,49 @@
package eu.kanade.tachiyomi.ui.anime.track
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.databinding.TrackItemBinding
class TrackAdapter(listener: OnClickListener) : RecyclerView.Adapter<TrackHolder>() {
private lateinit var binding: TrackItemBinding
var items = emptyList<TrackItem>()
set(value) {
if (field !== value) {
field = value
notifyDataSetChanged()
}
}
val rowClickListener: OnClickListener = listener
fun getItem(index: Int): TrackItem? {
return items.getOrNull(index)
}
override fun getItemCount(): Int {
return items.size
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackHolder {
binding = TrackItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return TrackHolder(binding, this)
}
override fun onBindViewHolder(holder: TrackHolder, position: Int) {
holder.bind(items[position])
}
interface OnClickListener {
fun onLogoClick(position: Int)
fun onSetClick(position: Int)
fun onTitleLongClick(position: Int)
fun onStatusClick(position: Int)
fun onEpisodesClick(position: Int)
fun onScoreClick(position: Int)
fun onStartDateClick(position: Int)
fun onFinishDateClick(position: Int)
}
}

View file

@ -0,0 +1,66 @@
package eu.kanade.tachiyomi.ui.anime.track
import android.annotation.SuppressLint
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.TrackItemBinding
import uy.kohesive.injekt.injectLazy
import java.text.DateFormat
class TrackHolder(private val binding: TrackItemBinding, adapter: TrackAdapter) : RecyclerView.ViewHolder(binding.root) {
private val preferences: PreferencesHelper by injectLazy()
private val dateFormat: DateFormat by lazy {
preferences.dateFormat()
}
init {
val listener = adapter.rowClickListener
binding.logoContainer.setOnClickListener { listener.onLogoClick(bindingAdapterPosition) }
binding.trackSet.setOnClickListener { listener.onSetClick(bindingAdapterPosition) }
binding.trackTitle.setOnClickListener { listener.onSetClick(bindingAdapterPosition) }
binding.trackTitle.setOnLongClickListener {
listener.onTitleLongClick(bindingAdapterPosition)
true
}
binding.trackStatus.setOnClickListener { listener.onStatusClick(bindingAdapterPosition) }
binding.trackChapters.setOnClickListener { listener.onChaptersClick(bindingAdapterPosition) }
binding.trackScore.setOnClickListener { listener.onScoreClick(bindingAdapterPosition) }
binding.trackStartDate.setOnClickListener { listener.onStartDateClick(bindingAdapterPosition) }
binding.trackFinishDate.setOnClickListener { listener.onFinishDateClick(bindingAdapterPosition) }
}
@SuppressLint("SetTextI18n")
fun bind(item: TrackItem) {
val track = item.track
binding.trackLogo.setImageResource(item.service.getLogo())
binding.logoContainer.setBackgroundColor(item.service.getLogoColor())
binding.trackSet.isVisible = track == null
binding.trackTitle.isVisible = track != null
binding.trackDetails.isVisible = track != null
if (track != null) {
binding.trackTitle.text = track.title
binding.trackChapters.text = "${track.last_chapter_read}/" +
if (track.total_chapters > 0) track.total_chapters else "-"
binding.trackStatus.text = item.service.getStatus(track.status)
binding.trackScore.text = if (track.score == 0f) "-" else item.service.displayScore(track)
if (item.service.supportsReadingDates) {
binding.trackStartDate.text =
if (track.started_reading_date != 0L) dateFormat.format(track.started_reading_date) else "-"
binding.trackFinishDate.text =
if (track.finished_reading_date != 0L) dateFormat.format(track.finished_reading_date) else "-"
} else {
binding.bottomDivider.isVisible = false
binding.vertDivider3.isVisible = false
binding.trackStartDate.isVisible = false
binding.trackFinishDate.isVisible = false
}
}
}
}

View file

@ -0,0 +1,6 @@
package eu.kanade.tachiyomi.ui.anime.track
import eu.kanade.tachiyomi.data.database.models.AnimeTrack
import eu.kanade.tachiyomi.data.track.TrackService
data class TrackItem(val track: AnimeTrack?, val service: TrackService)

View file

@ -0,0 +1,80 @@
package eu.kanade.tachiyomi.ui.anime.track
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import androidx.core.view.isVisible
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.databinding.TrackSearchItemBinding
import eu.kanade.tachiyomi.util.view.inflate
class TrackSearchAdapter(context: Context) :
ArrayAdapter<TrackSearch>(context, R.layout.track_search_item, mutableListOf<TrackSearch>()) {
override fun getView(position: Int, view: View?, parent: ViewGroup): View {
var v = view
// Get the data item for this position
val track = getItem(position)!!
// Check if an existing view is being reused, otherwise inflate the view
val holder: TrackSearchHolder // view lookup cache stored in tag
if (v == null) {
v = parent.inflate(R.layout.track_search_item)
holder = TrackSearchHolder(v)
v.tag = holder
} else {
holder = v.tag as TrackSearchHolder
}
holder.onSetValues(track)
return v
}
fun setItems(syncs: List<TrackSearch>) {
setNotifyOnChange(false)
clear()
addAll(syncs)
notifyDataSetChanged()
}
class TrackSearchHolder(private val view: View) {
private val binding = TrackSearchItemBinding.bind(view)
fun onSetValues(track: TrackSearch) {
binding.trackSearchTitle.text = track.title
binding.trackSearchSummary.text = track.summary
GlideApp.with(view.context).clear(binding.trackSearchCover)
if (track.cover_url.isNotEmpty()) {
GlideApp.with(view.context)
.load(track.cover_url)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(binding.trackSearchCover)
}
val hasStatus = track.publishing_status.isNotBlank()
binding.trackSearchStatus.isVisible = hasStatus
binding.trackSearchStatusResult.isVisible = hasStatus
if (hasStatus) {
binding.trackSearchStatusResult.text = track.publishing_status.capitalize()
}
val hasType = track.publishing_type.isNotBlank()
binding.trackSearchType.isVisible = hasType
binding.trackSearchTypeResult.isVisible = hasType
if (hasType) {
binding.trackSearchTypeResult.text = track.publishing_type.capitalize()
}
val hasStartDate = track.start_date.isNotBlank()
binding.trackSearchStart.isVisible = hasStartDate
binding.trackSearchStartResult.isVisible = hasStartDate
if (hasStartDate) {
binding.trackSearchStartResult.text = track.start_date
}
}
}
}

View file

@ -0,0 +1,138 @@
package eu.kanade.tachiyomi.ui.anime.track
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.customview.customView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.databinding.TrackSearchDialogBinding
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.anime.AnimeController
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.widget.itemClicks
import reactivecircus.flowbinding.android.widget.textChanges
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
class TrackSearchDialog : DialogController {
private var binding: TrackSearchDialogBinding? = null
private var adapter: TrackSearchAdapter? = null
private var selectedItem: Track? = null
private val service: TrackService
private val trackController
get() = targetController as AnimeController
constructor(target: AnimeController, service: TrackService) : super(
bundleOf(KEY_SERVICE to service.id)
) {
targetController = target
this.service = service
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
service = Injekt.get<TrackManager>().getService(bundle.getInt(KEY_SERVICE))!!
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
binding = TrackSearchDialogBinding.inflate(LayoutInflater.from(activity!!))
val dialog = MaterialDialog(activity!!)
.customView(view = binding!!.root)
.positiveButton(android.R.string.ok) { onPositiveButtonClick() }
.negativeButton(android.R.string.cancel)
.neutralButton(R.string.action_remove) { onRemoveButtonClick() }
onViewCreated(dialog.view, savedViewState)
return dialog
}
fun onViewCreated(view: View, savedState: Bundle?) {
// Create adapter
val adapter = TrackSearchAdapter(view.context)
this.adapter = adapter
binding!!.trackSearchList.adapter = adapter
// Set listeners
selectedItem = null
binding!!.trackSearchList.itemClicks()
.onEach { position ->
selectedItem = adapter.getItem(position)
}
.launchIn(trackController.viewScope)
// Do an initial search based on the manga's title
if (savedState == null) {
val title = trackController.presenter.anime.title
binding!!.trackSearch.append(title)
search(title)
}
}
override fun onDestroyView(view: View) {
super.onDestroyView(view)
binding = null
adapter = null
}
override fun onAttach(view: View) {
super.onAttach(view)
binding!!.trackSearch.textChanges()
.debounce(TimeUnit.SECONDS.toMillis(1))
.filter { it.isNotBlank() }
.onEach { search(it.toString()) }
.launchIn(trackController.viewScope)
}
private fun search(query: String) {
val binding = binding ?: return
binding.progress.isVisible = true
binding.trackSearchList.isVisible = false
trackController.presenter.trackingSearch(query, service)
}
fun onSearchResults(results: List<TrackSearch>) {
selectedItem = null
val binding = binding ?: return
binding.progress.isVisible = false
binding.trackSearchList.isVisible = true
adapter?.setItems(results)
}
fun onSearchResultsError() {
val binding = binding ?: return
binding.progress.isVisible = false
binding.trackSearchList.isVisible = false
adapter?.setItems(emptyList())
}
private fun onPositiveButtonClick() {
trackController.presenter.registerTracking(selectedItem, service)
}
private fun onRemoveButtonClick() {
trackController.presenter.unregisterTracking(service)
}
private companion object {
const val KEY_SERVICE = "service_id"
}
}

View file

@ -0,0 +1,143 @@
package eu.kanade.tachiyomi.ui.anime.track
import android.os.Bundle
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.databinding.TrackControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.openInBrowser
import eu.kanade.tachiyomi.ui.anime.AnimeController
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog
class TrackSheet(
val controller: AnimeController,
val anime: Anime
) : BaseBottomSheetDialog(controller.activity!!),
TrackAdapter.OnClickListener,
SetTrackStatusDialog.Listener,
SetTrackEpisodesDialog.Listener,
SetTrackScoreDialog.Listener,
SetTrackWatchingDatesDialog.Listener {
private lateinit var binding: TrackControllerBinding
private lateinit var sheetBehavior: BottomSheetBehavior<*>
private lateinit var adapter: TrackAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = TrackControllerBinding.inflate(layoutInflater)
setContentView(binding.root)
adapter = TrackAdapter(this)
binding.trackRecycler.layoutManager = LinearLayoutManager(context)
binding.trackRecycler.adapter = adapter
sheetBehavior = BottomSheetBehavior.from(binding.root.parent as ViewGroup)
adapter.items = controller.presenter.trackList
}
override fun onStart() {
super.onStart()
sheetBehavior.skipCollapsed = true
}
override fun show() {
super.show()
controller.presenter.refreshTrackers()
sheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
}
fun onNextTrackers(trackers: List<TrackItem>) {
if (this::adapter.isInitialized) {
adapter.items = trackers
adapter.notifyDataSetChanged()
}
}
override fun onLogoClick(position: Int) {
val track = adapter.getItem(position)?.track ?: return
if (track.tracking_url.isNotBlank()) {
controller.openInBrowser(track.tracking_url)
}
}
override fun onSetClick(position: Int) {
val item = adapter.getItem(position) ?: return
TrackSearchDialog(controller, item.service).showDialog(controller.router, TAG_SEARCH_CONTROLLER)
}
override fun onTitleLongClick(position: Int) {
adapter.getItem(position)?.track?.title?.let {
controller.activity?.copyToClipboard(it, it)
}
}
override fun onStatusClick(position: Int) {
val item = adapter.getItem(position) ?: return
if (item.track == null) return
SetTrackStatusDialog(controller, this, item).showDialog(controller.router)
}
override fun onEpisodesClick(position: Int) {
val item = adapter.getItem(position) ?: return
if (item.track == null) return
SetTrackEpisodesDialog(controller, this, item).showDialog(controller.router)
}
override fun onScoreClick(position: Int) {
val item = adapter.getItem(position) ?: return
if (item.track == null) return
SetTrackScoreDialog(controller, this, item).showDialog(controller.router)
}
override fun onStartDateClick(position: Int) {
val item = adapter.getItem(position) ?: return
if (item.track == null) return
SetTrackWatchingDatesDialog(controller, this, SetTrackWatchingDatesDialog.ReadingDate.Start, item).showDialog(controller.router)
}
override fun onFinishDateClick(position: Int) {
val item = adapter.getItem(position) ?: return
if (item.track == null) return
SetTrackWatchingDatesDialog(controller, this, SetTrackWatchingDatesDialog.ReadingDate.Finish, item).showDialog(controller.router)
}
override fun setStatus(item: TrackItem, selection: Int) {
controller.presenter.setTrackerStatus(item, selection)
}
override fun setEpisodesRead(item: TrackItem, episodesRead: Int) {
controller.presenter.setTrackerLastEpisodeRead(item, episodesRead)
}
override fun setScore(item: TrackItem, score: Int) {
controller.presenter.setTrackerScore(item, score)
}
override fun setReadingDate(item: TrackItem, type: SetTrackWatchingDatesDialog.ReadingDate, date: Long) {
when (type) {
SetTrackWatchingDatesDialog.ReadingDate.Start -> controller.presenter.setTrackerStartDate(item, date)
SetTrackWatchingDatesDialog.ReadingDate.Finish -> controller.presenter.setTrackerFinishDate(item, date)
}
}
fun getSearchDialog(): TrackSearchDialog? {
return controller.router.getControllerWithTag(TAG_SEARCH_CONTROLLER) as? TrackSearchDialog
}
private companion object {
const val TAG_SEARCH_CONTROLLER = "track_search_controller"
}
}

View file

@ -50,6 +50,15 @@ open class BasePresenter<V> : RxPresenter<V>() {
*/
fun <T> Observable<T>.subscribeLatestCache(onNext: (V, T) -> Unit, onError: ((V, Throwable) -> Unit)? = null) = compose(deliverLatestCache<T>()).subscribe(split(onNext, onError)).apply { add(this) }
/**
* Subscribes an observable with [deliverLatestCache] and adds it to the presenter's lifecycle
* subscription list.
*
* @param onNext function to execute when the observable emits an item.
* @param onError function to execute when the observable throws an error.
*/
fun <T> Observable<T>.subscribeLatestAnimeCache(onNext: (V, T) -> Unit, onError: ((V, Throwable) -> Unit)? = null) = compose(deliverLatestCache<T>()).subscribe(split(onNext, onError)).apply { add(this) }
/**
* Subscribes an observable with [deliverReplay] and adds it to the presenter's lifecycle
* subscription list.

View file

@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.ui.browse.migration
import eu.kanade.tachiyomi.R
object AnimeMigrationFlags {
private const val EPISODES = 0b001
private const val CATEGORIES = 0b010
private const val TRACK = 0b100
private const val EPISODES2 = 0x1
private const val CATEGORIES2 = 0x2
private const val TRACK2 = 0x4
val titles get() = arrayOf(R.string.chapters, R.string.categories, R.string.track)
val flags get() = arrayOf(EPISODES, CATEGORIES, TRACK)
fun hasEpisodes(value: Int): Boolean {
return value and EPISODES != 0
}
fun hasCategories(value: Int): Boolean {
return value and CATEGORIES != 0
}
fun hasTracks(value: Int): Boolean {
return value and TRACK != 0
}
fun getEnabledFlagsPositions(value: Int): List<Int> {
return flags.mapIndexedNotNull { index, flag -> if (value and flag != 0) index else null }
}
fun getFlagsFromPositions(positions: Array<Int>): Int {
return positions.fold(0, { accumulated, position -> accumulated or (1 shl position) })
}
}

View file

@ -12,7 +12,7 @@ import eu.kanade.tachiyomi.databinding.MigrationMangaControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.AnimeController
class MigrationMangaController :
NucleusController<MigrationMangaControllerBinding, MigrationMangaPresenter>,
@ -82,7 +82,7 @@ class MigrationMangaController :
override fun onCoverClick(position: Int) {
val mangaItem = adapter?.getItem(position) as? MigrationMangaItem ?: return
router.pushController(MangaController(mangaItem.manga).withFadeTransaction())
router.pushController(AnimeController(mangaItem.manga).withFadeTransaction())
}
companion object {

View file

@ -0,0 +1,131 @@
package eu.kanade.tachiyomi.ui.browse.migration.search
import android.app.Dialog
import android.os.Bundle
import androidx.core.view.isVisible
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItemsMultiChoice
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalAnimeSearchController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalAnimeSearchPresenter
import uy.kohesive.injekt.injectLazy
class AnimeSearchController(
private var anime: Anime? = null
) : GlobalAnimeSearchController(anime?.title) {
private var newAnime: Anime? = null
override fun createPresenter(): GlobalAnimeSearchPresenter {
return AnimeSearchPresenter(
initialQuery,
anime!!
)
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putSerializable(::anime.name, anime)
outState.putSerializable(::newAnime.name, newAnime)
super.onSaveInstanceState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
anime = savedInstanceState.getSerializable(::anime.name) as? Anime
newAnime = savedInstanceState.getSerializable(::newAnime.name) as? Anime
}
fun migrateAnime(anime: Anime? = null, newAnime: Anime?) {
anime ?: return
newAnime ?: return
(presenter as? AnimeSearchPresenter)?.migrateAnime(anime, newAnime, true)
}
fun copyAnime(anime: Anime? = null, newAnime: Anime?) {
anime ?: return
newAnime ?: return
(presenter as? AnimeSearchPresenter)?.migrateAnime(anime, newAnime, false)
}
override fun onAnimeClick(anime: Anime) {
newAnime = anime
val dialog =
MigrationDialog(this.anime, newAnime, this)
dialog.targetController = this
dialog.showDialog(router)
}
override fun onAnimeLongClick(anime: Anime) {
// Call parent's default click listener
super.onAnimeClick(anime)
}
fun renderIsReplacingAnime(isReplacingAnime: Boolean) {
if (isReplacingAnime) {
binding.progress.isVisible = true
} else {
binding.progress.isVisible = false
router.popController(this)
}
}
class MigrationDialog(private val anime: Anime? = null, private val newAnime: Anime? = null, private val callingController: Controller? = null) : DialogController() {
private val preferences: PreferencesHelper by injectLazy()
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val prefValue = preferences.migrateFlags().get()
val preselected =
MigrationFlags.getEnabledFlagsPositions(
prefValue
)
return MaterialDialog(activity!!)
.title(R.string.migration_dialog_what_to_include)
.listItemsMultiChoice(
items = MigrationFlags.titles.map { resources?.getString(it) as CharSequence },
initialSelection = preselected.toIntArray()
) { _, positions, _ ->
// Save current settings for the next time
val newValue =
MigrationFlags.getFlagsFromPositions(
positions.toTypedArray()
)
preferences.migrateFlags().set(newValue)
}
.positiveButton(R.string.migrate) {
if (callingController != null) {
if (callingController.javaClass == AnimeSourceSearchController::class.java) {
router.popController(callingController)
}
}
(targetController as? AnimeSearchController)?.migrateAnime(anime, newAnime)
}
.negativeButton(R.string.copy) {
if (callingController != null) {
if (callingController.javaClass == AnimeSourceSearchController::class.java) {
router.popController(callingController)
}
}
(targetController as? AnimeSearchController)?.copyAnime(anime, newAnime)
}
.neutralButton(android.R.string.cancel)
}
}
override fun onTitleClick(source: CatalogueSource) {
presenter.preferences.lastUsedSource().set(source.id)
router.pushController(AnimeSourceSearchController(anime, source, presenter.query).withFadeTransaction())
}
}

View file

@ -0,0 +1,169 @@
package eu.kanade.tachiyomi.ui.browse.migration.search
import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.database.models.AnimeCategory
import eu.kanade.tachiyomi.data.database.models.toAnimeInfo
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SEpisode
import eu.kanade.tachiyomi.source.model.SAnime
import eu.kanade.tachiyomi.source.model.toSEpisode
import eu.kanade.tachiyomi.ui.browse.migration.AnimeMigrationFlags
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalAnimeSearchCardItem
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalAnimeSearchItem
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalAnimeSearchPresenter
import eu.kanade.tachiyomi.util.episode.syncEpisodesWithSource
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.toast
import java.util.Date
class AnimeSearchPresenter(
initialQuery: String? = "",
private val anime: Anime
) : GlobalAnimeSearchPresenter(initialQuery) {
private val replacingAnimeRelay = BehaviorRelay.create<Boolean>()
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
replacingAnimeRelay.subscribeLatestCache({ controller, isReplacingAnime -> (controller as? AnimeSearchController)?.renderIsReplacingAnime(isReplacingAnime) })
}
override fun getEnabledSources(): List<CatalogueSource> {
// Put the source of the selected anime at the top
return super.getEnabledSources()
.sortedByDescending { it.id == anime.source }
}
override fun createCatalogueSearchItem(source: CatalogueSource, results: List<GlobalAnimeSearchCardItem>?): GlobalAnimeSearchItem {
// Set the catalogue search item as highlighted if the source matches that of the selected anime
return GlobalAnimeSearchItem(source, results, source.id == anime.source)
}
override fun networkToLocalAnime(sAnime: SAnime, sourceId: Long): Anime {
val localAnime = super.networkToLocalAnime(sAnime, sourceId)
// For migration, displayed title should always match source rather than local DB
localAnime.title = sAnime.title
return localAnime
}
fun migrateAnime(prevAnime: Anime, anime: Anime, replace: Boolean) {
val source = sourceManager.get(anime.source) ?: return
replacingAnimeRelay.call(true)
presenterScope.launchIO {
try {
val episodes = source.getEpisodeList(anime.toAnimeInfo())
.map { it.toSEpisode() }
migrateAnimeInternal(source, episodes, prevAnime, anime, replace)
} catch (e: Throwable) {
withUIContext { view?.applicationContext?.toast(e.message) }
}
presenterScope.launchUI { replacingAnimeRelay.call(false) }
}
}
private fun migrateAnimeInternal(
source: Source,
sourceEpisodes: List<SEpisode>,
prevAnime: Anime,
anime: Anime,
replace: Boolean
) {
val flags = preferences.migrateFlags().get()
val migrateEpisodes =
AnimeMigrationFlags.hasEpisodes(
flags
)
val migrateCategories =
AnimeMigrationFlags.hasCategories(
flags
)
val migrateTracks =
AnimeMigrationFlags.hasTracks(
flags
)
db.inTransaction {
// Update episodes read
if (migrateEpisodes) {
try {
syncEpisodesWithSource(db, sourceEpisodes, anime, source)
} catch (e: Exception) {
// Worst case, episodes won't be synced
}
val prevAnimeEpisodes = db.getEpisodes(prevAnime).executeAsBlocking()
val maxEpisodeRead = prevAnimeEpisodes
.filter { it.read }
.maxOfOrNull { it.episode_number }
if (maxEpisodeRead != null) {
val dbEpisodes = db.getEpisodes(anime).executeAsBlocking()
for (episode in dbEpisodes) {
if (episode.isRecognizedNumber) {
val prevEpisode = prevAnimeEpisodes
.find { it.isRecognizedNumber && it.episode_number == episode.episode_number }
if (prevEpisode != null) {
episode.date_fetch = prevEpisode.date_fetch
episode.bookmark = prevEpisode.bookmark
} else if (episode.episode_number <= maxEpisodeRead) {
episode.read = true
}
}
}
db.insertEpisodes(dbEpisodes).executeAsBlocking()
}
}
// Update categories
if (migrateCategories) {
val categories = db.getCategoriesForAnime(prevAnime).executeAsBlocking()
val animeCategories = categories.map { AnimeCategory.create(anime, it) }
db.setAnimeCategories(animeCategories, listOf(anime))
}
// Update track
if (migrateTracks) {
val tracks = db.getTracks(prevAnime).executeAsBlocking()
for (track in tracks) {
track.id = null
track.anime_id = anime.id!!
}
db.insertTracks(tracks).executeAsBlocking()
}
// Update favorite status
if (replace) {
prevAnime.favorite = false
db.updateAnimeFavorite(prevAnime).executeAsBlocking()
}
anime.favorite = true
db.updateAnimeFavorite(anime).executeAsBlocking()
// Update reading preferences
anime.episode_flags = prevAnime.episode_flags
db.updateFlags(anime).executeAsBlocking()
anime.viewer = prevAnime.viewer
db.updateAnimeViewer(anime).executeAsBlocking()
// Update date added
if (replace) {
anime.date_added = prevAnime.date_added
prevAnime.date_added = 0
} else {
anime.date_added = Date().time
}
// SearchPresenter#networkToLocalAnime may have updated the anime title, so ensure db gets updated title
db.updateAnimeTitle(anime).executeAsBlocking()
}
}
}

View file

@ -0,0 +1,39 @@
package eu.kanade.tachiyomi.ui.browse.migration.search
import android.os.Bundle
import android.view.View
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.AnimeSourceItem
class AnimeSourceSearchController(
bundle: Bundle
) : BrowseSourceController(bundle) {
constructor(anime: Anime? = null, source: CatalogueSource, searchQuery: String? = null) : this(
Bundle().apply {
putLong(SOURCE_ID_KEY, source.id)
putSerializable(ANIME_KEY, anime)
if (searchQuery != null) {
putString(SEARCH_QUERY_KEY, searchQuery)
}
}
)
private var oldAnime: Anime? = args.getSerializable(ANIME_KEY) as Anime?
private var newAnime: Anime? = null
override fun onItemClick(view: View, position: Int): Boolean {
val item = adapter?.getItem(position) as? AnimeSourceItem ?: return false
newAnime = item.anime
val searchController = router.backstack.findLast { it.controller().javaClass == AnimeSearchController::class.java }?.controller() as AnimeSearchController?
val dialog =
AnimeSearchController.MigrationDialog(oldAnime, newAnime, this)
dialog.targetController = searchController
dialog.showDialog(router)
return true
}
private companion object {
const val ANIME_KEY = "oldAnime"
}
}

View file

@ -0,0 +1,56 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import android.view.View
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.toAnimeThumbnail
import eu.kanade.tachiyomi.databinding.AnimeSourceComfortableGridItemBinding
import eu.kanade.tachiyomi.databinding.SourceComfortableGridItemBinding
import eu.kanade.tachiyomi.widget.StateImageViewTarget
/**
* Class used to hold the displayed data of a anime in the catalogue, like the cover or the title.
* All the elements from the layout file "item_source_grid" are available in this class.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @constructor creates a new catalogue holder.
*/
class AnimeSourceComfortableGridHolder(private val view: View, private val adapter: FlexibleAdapter<*>) :
AnimeSourceHolder<AnimeSourceComfortableGridItemBinding>(view, adapter) {
override val binding = AnimeSourceComfortableGridItemBinding.bind(view)
/**
* Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this
* holder with the given anime.
*
* @param anime the anime to bind.
*/
override fun onSetValues(anime: Anime) {
// Set anime title
binding.title.text = anime.title
// Set alpha of thumbnail.
binding.thumbnail.alpha = if (anime.favorite) 0.3f else 1.0f
setImage(anime)
}
override fun setImage(anime: Anime) {
// For rounded corners
binding.card.clipToOutline = true
GlideApp.with(view.context).clear(binding.thumbnail)
if (!anime.thumbnail_url.isNullOrEmpty()) {
GlideApp.with(view.context)
.load(anime.toAnimeThumbnail())
.diskCacheStrategy(DiskCacheStrategy.DATA)
.centerCrop()
.placeholder(android.R.color.transparent)
.into(StateImageViewTarget(binding.thumbnail, binding.progress))
}
}
}

View file

@ -0,0 +1,56 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import android.view.View
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.toAnimeThumbnail
import eu.kanade.tachiyomi.databinding.AnimeSourceComfortableGridItemBinding
import eu.kanade.tachiyomi.databinding.SourceComfortableGridItemBinding
import eu.kanade.tachiyomi.widget.StateImageViewTarget
/**
* Class used to hold the displayed data of a anime in the catalogue, like the cover or the title.
* All the elements from the layout file "item_source_grid" are available in this class.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @constructor creates a new catalogue holder.
*/
open class AnimeSourceGridHolder(private val view: View, private val adapter: FlexibleAdapter<*>) :
AnimeSourceHolder<AnimeSourceComfortableGridItemBinding>(view, adapter) {
override val binding = AnimeSourceComfortableGridItemBinding.bind(view)
/**
* Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this
* holder with the given anime.
*
* @param anime the anime to bind.
*/
override fun onSetValues(anime: Anime) {
// Set anime title
binding.title.text = anime.title
// Set alpha of thumbnail.
binding.thumbnail.alpha = if (anime.favorite) 0.3f else 1.0f
setImage(anime)
}
override fun setImage(anime: Anime) {
// For rounded corners
binding.card.clipToOutline = true
GlideApp.with(view.context).clear(binding.thumbnail)
if (!anime.thumbnail_url.isNullOrEmpty()) {
GlideApp.with(view.context)
.load(anime.toAnimeThumbnail())
.diskCacheStrategy(DiskCacheStrategy.DATA)
.centerCrop()
.placeholder(android.R.color.transparent)
.into(StateImageViewTarget(binding.thumbnail, binding.progress))
}
}
}

View file

@ -0,0 +1,35 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import android.view.View
import androidx.viewbinding.ViewBinding
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.data.database.models.Anime
/**
* Generic class used to hold the displayed data of a anime in the catalogue.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
*/
abstract class AnimeSourceHolder<VB : ViewBinding>(view: View, adapter: FlexibleAdapter<*>) :
FlexibleViewHolder(view, adapter) {
abstract val binding: VB
/**
* Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this
* holder with the given anime.
*
* @param anime the anime to bind.
*/
abstract fun onSetValues(anime: Anime)
/**
* Updates the image for this holder. Useful to update the image when the anime is initialized
* and the url is now known.
*
* @param anime the anime to bind.
*/
abstract fun setImage(anime: Anime)
}

View file

@ -0,0 +1,91 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import android.view.Gravity
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView
import com.tfcporciuncula.flow.Preference
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.preference.PreferenceValues.DisplayMode
import eu.kanade.tachiyomi.databinding.AnimeSourceComfortableGridItemBinding
import eu.kanade.tachiyomi.databinding.AnimeSourceCompactGridItemBinding
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
class AnimeSourceItem(val anime: Anime, private val displayMode: Preference<DisplayMode>) :
AbstractFlexibleItem<AnimeSourceHolder<*>>() {
override fun getLayoutRes(): Int {
return when (displayMode.get()) {
DisplayMode.COMPACT_GRID -> R.layout.source_compact_grid_item
DisplayMode.COMFORTABLE_GRID -> R.layout.source_comfortable_grid_item
DisplayMode.LIST -> R.layout.source_list_item
}
}
override fun createViewHolder(
view: View,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>
): AnimeSourceHolder<*> {
return when (displayMode.get()) {
DisplayMode.COMPACT_GRID -> {
val binding = AnimeSourceCompactGridItemBinding.bind(view)
val parent = adapter.recyclerView as AutofitRecyclerView
val coverHeight = parent.itemWidth / 3 * 4
view.apply {
binding.card.layoutParams = FrameLayout.LayoutParams(
MATCH_PARENT,
coverHeight
)
binding.gradient.layoutParams = FrameLayout.LayoutParams(
MATCH_PARENT,
coverHeight / 2,
Gravity.BOTTOM
)
}
AnimeSourceGridHolder(view, adapter)
}
DisplayMode.COMFORTABLE_GRID -> {
val binding = AnimeSourceComfortableGridItemBinding.bind(view)
val parent = adapter.recyclerView as AutofitRecyclerView
val coverHeight = parent.itemWidth / 3 * 4
view.apply {
binding.card.layoutParams = ConstraintLayout.LayoutParams(
MATCH_PARENT,
coverHeight
)
}
AnimeSourceComfortableGridHolder(view, adapter)
}
DisplayMode.LIST -> {
AnimeSourceListHolder(view, adapter)
}
}
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: AnimeSourceHolder<*>,
position: Int,
payloads: List<Any?>?
) {
holder.onSetValues(anime)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is AnimeSourceItem) {
return anime.id!! == other.anime.id!!
}
return false
}
override fun hashCode(): Int {
return anime.id!!.hashCode()
}
}

View file

@ -0,0 +1,64 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import android.view.View
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.RequestOptions
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.toAnimeThumbnail
import eu.kanade.tachiyomi.databinding.AnimeSourceListItemBinding
import eu.kanade.tachiyomi.databinding.SourceListItemBinding
import eu.kanade.tachiyomi.util.system.getResourceColor
/**
* Class used to hold the displayed data of a anime in the catalogue, like the cover or the title.
* All the elements from the layout file "item_catalogue_list" are available in this class.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @constructor creates a new catalogue holder.
*/
class AnimeSourceListHolder(private val view: View, adapter: FlexibleAdapter<*>) :
AnimeSourceHolder<AnimeSourceListItemBinding>(view, adapter) {
override val binding = AnimeSourceListItemBinding.bind(view)
private val favoriteColor = view.context.getResourceColor(R.attr.colorOnSurface, 0.38f)
private val unfavoriteColor = view.context.getResourceColor(R.attr.colorOnSurface)
/**
* Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this
* holder with the given anime.
*
* @param anime the anime to bind.
*/
override fun onSetValues(anime: Anime) {
binding.title.text = anime.title
binding.title.setTextColor(if (anime.favorite) favoriteColor else unfavoriteColor)
// Set alpha of thumbnail.
binding.thumbnail.alpha = if (anime.favorite) 0.3f else 1.0f
setImage(anime)
}
override fun setImage(anime: Anime) {
GlideApp.with(view.context).clear(binding.thumbnail)
if (!anime.thumbnail_url.isNullOrEmpty()) {
val radius = view.context.resources.getDimensionPixelSize(R.dimen.card_radius)
val requestOptions = RequestOptions().transform(CenterCrop(), RoundedCorners(radius))
GlideApp.with(view.context)
.load(anime.toAnimeThumbnail())
.diskCacheStrategy(DiskCacheStrategy.DATA)
.apply(requestOptions)
.dontAnimate()
.placeholder(android.R.color.transparent)
.into(binding.thumbnail)
}
}
}

View file

@ -0,0 +1,81 @@
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
import android.os.Bundle
import android.os.Parcelable
import android.util.SparseArray
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.source.CatalogueSource
/**
* Adapter that holds the search cards.
*
* @param controller instance of [GlobalSearchController].
*/
class GlobalAnimeSearchAdapter(val controller: GlobalAnimeSearchController) :
FlexibleAdapter<GlobalAnimeSearchItem>(null, controller, true) {
val titleClickListener: OnTitleClickListener = controller
/**
* Bundle where the view state of the holders is saved.
*/
private var bundle = Bundle()
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List<Any?>) {
super.onBindViewHolder(holder, position, payloads)
restoreHolderState(holder)
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
super.onViewRecycled(holder)
saveHolderState(holder, bundle)
}
override fun onSaveInstanceState(outState: Bundle) {
val holdersBundle = Bundle()
allBoundViewHolders.forEach { saveHolderState(it, holdersBundle) }
outState.putBundle(HOLDER_BUNDLE_KEY, holdersBundle)
super.onSaveInstanceState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
bundle = savedInstanceState.getBundle(HOLDER_BUNDLE_KEY)!!
}
/**
* Saves the view state of the given holder.
*
* @param holder The holder to save.
* @param outState The bundle where the state is saved.
*/
private fun saveHolderState(holder: RecyclerView.ViewHolder, outState: Bundle) {
val key = "holder_${holder.bindingAdapterPosition}"
val holderState = SparseArray<Parcelable>()
holder.itemView.saveHierarchyState(holderState)
outState.putSparseParcelableArray(key, holderState)
}
/**
* Restores the view state of the given holder.
*
* @param holder The holder to restore.
*/
private fun restoreHolderState(holder: RecyclerView.ViewHolder) {
val key = "holder_${holder.bindingAdapterPosition}"
val holderState = bundle.getSparseParcelableArray<Parcelable>(key)
if (holderState != null) {
holder.itemView.restoreHierarchyState(holderState)
bundle.remove(key)
}
}
interface OnTitleClickListener {
fun onTitleClick(source: CatalogueSource)
}
private companion object {
const val HOLDER_BUNDLE_KEY = "holder_bundle"
}
}

View file

@ -0,0 +1,27 @@
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.database.models.Anime
/**
* Adapter that holds the anime items from search results.
*
* @param controller instance of [GlobalSearchController].
*/
class GlobalAnimeSearchCardAdapter(controller: GlobalAnimeSearchController) :
FlexibleAdapter<GlobalAnimeSearchCardItem>(null, controller, true) {
/**
* Listen for browse item clicks.
*/
val animeClickListener: OnAnimeClickListener = controller
/**
* Listener which should be called when user clicks browse.
* Note: Should only be handled by [GlobalSearchController]
*/
interface OnAnimeClickListener {
fun onAnimeClick(anime: Anime)
fun onAnimeLongClick(anime: Anime)
}
}

View file

@ -0,0 +1,56 @@
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
import android.view.View
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.toAnimeThumbnail
import eu.kanade.tachiyomi.databinding.GlobalSearchControllerCardItemBinding
import eu.kanade.tachiyomi.widget.StateImageViewTarget
class GlobalAnimeSearchCardHolder(view: View, adapter: GlobalAnimeSearchCardAdapter) :
FlexibleViewHolder(view, adapter) {
private val binding = GlobalSearchControllerCardItemBinding.bind(view)
init {
// Call onAnimeClickListener when item is pressed.
itemView.setOnClickListener {
val item = adapter.getItem(bindingAdapterPosition)
if (item != null) {
adapter.animeClickListener.onAnimeClick(item.anime)
}
}
itemView.setOnLongClickListener {
val item = adapter.getItem(bindingAdapterPosition)
if (item != null) {
adapter.animeClickListener.onAnimeLongClick(item.anime)
}
true
}
}
fun bind(anime: Anime) {
binding.card.clipToOutline = true
binding.title.text = anime.title
// Set alpha of thumbnail.
binding.cover.alpha = if (anime.favorite) 0.3f else 1.0f
setImage(anime)
}
fun setImage(anime: Anime) {
GlideApp.with(itemView.context).clear(binding.cover)
if (!anime.thumbnail_url.isNullOrEmpty()) {
GlideApp.with(itemView.context)
.load(anime.toAnimeThumbnail())
.diskCacheStrategy(DiskCacheStrategy.DATA)
.centerCrop()
.skipMemoryCache(true)
.placeholder(android.R.color.transparent)
.into(StateImageViewTarget(binding.cover, binding.progress))
}
}
}

View file

@ -0,0 +1,40 @@
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Anime
class GlobalAnimeSearchCardItem(val anime: Anime) : AbstractFlexibleItem<GlobalAnimeSearchCardHolder>() {
override fun getLayoutRes(): Int {
return R.layout.global_search_controller_card_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): GlobalAnimeSearchCardHolder {
return GlobalAnimeSearchCardHolder(view, adapter as GlobalAnimeSearchCardAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: GlobalAnimeSearchCardHolder,
position: Int,
payloads: List<Any?>?
) {
holder.bind(anime)
}
override fun equals(other: Any?): Boolean {
if (other is GlobalAnimeSearchCardItem) {
return anime.id == other.anime.id
}
return false
}
override fun hashCode(): Int {
return anime.id?.toInt() ?: 0
}
}

View file

@ -0,0 +1,226 @@
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.GlobalAnimeSearchControllerBinding
import eu.kanade.tachiyomi.databinding.GlobalSearchControllerBinding
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.anime.AnimeController
import uy.kohesive.injekt.injectLazy
/**
* This controller shows and manages the different search result in global search.
* This controller should only handle UI actions, IO actions should be done by [GlobalSearchPresenter]
* [GlobalAnimeSearchCardAdapter.OnAnimeClickListener] called when anime is clicked in global search
*/
open class GlobalAnimeSearchController(
protected val initialQuery: String? = null,
protected val extensionFilter: String? = null
) : SearchableNucleusController<GlobalAnimeSearchControllerBinding, GlobalAnimeSearchPresenter>(),
GlobalAnimeSearchCardAdapter.OnAnimeClickListener,
GlobalAnimeSearchAdapter.OnTitleClickListener {
private val preferences: PreferencesHelper by injectLazy()
/**
* Adapter containing search results grouped by lang.
*/
protected var adapter: GlobalAnimeSearchAdapter? = null
/**
* Ref to the OptionsMenu.SearchItem created in onCreateOptionsMenu
*/
private var optionsMenuSearchItem: MenuItem? = null
init {
setHasOptionsMenu(true)
}
/**
* Initiate the view with [R.layout.global_search_controller].
*
* @param inflater used to load the layout xml.
* @param container containing parent views.
* @return inflated view
*/
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
binding = GlobalAnimeSearchControllerBinding.inflate(inflater)
binding.recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
return binding.root
}
override fun getTitle(): String? {
return presenter.query
}
/**
* Create the [GlobalSearchPresenter] used in controller.
*
* @return instance of [GlobalSearchPresenter]
*/
override fun createPresenter(): GlobalAnimeSearchPresenter {
return GlobalAnimeSearchPresenter(initialQuery, extensionFilter)
}
/**
* Called when anime in global search is clicked, opens anime.
*
* @param anime clicked item containing anime information.
*/
override fun onAnimeClick(anime: Anime) {
router.pushController(AnimeController(anime, true).withFadeTransaction())
}
/**
* Called when anime in global search is long clicked.
*
* @param anime clicked item containing anime information.
*/
override fun onAnimeLongClick(anime: Anime) {
// Delegate to single click by default.
onAnimeClick(anime)
}
/**
* Adds items to the options menu.
*
* @param menu menu containing options.
* @param inflater used to load the menu xml.
*/
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
createOptionsMenu(
menu,
inflater,
R.menu.global_search,
R.id.action_search,
null,
false // the onMenuItemActionExpand will handle this
)
optionsMenuSearchItem = menu.findItem(R.id.action_search)
}
override fun onSearchMenuItemActionExpand(item: MenuItem?) {
super.onSearchMenuItemActionExpand(item)
val searchView = optionsMenuSearchItem?.actionView as SearchView
searchView.onActionViewExpanded() // Required to show the query in the view
if (nonSubmittedQuery.isBlank()) {
searchView.setQuery(presenter.query, false)
}
}
override fun onSearchViewQueryTextSubmit(query: String?) {
presenter.search(query ?: "")
optionsMenuSearchItem?.collapseActionView()
setTitle() // Update toolbar title
}
/**
* Called when the view is created
*
* @param view view of controller
*/
override fun onViewCreated(view: View) {
super.onViewCreated(view)
adapter = GlobalAnimeSearchAdapter(this)
// Create recycler and set adapter.
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.adapter = adapter
}
override fun onDestroyView(view: View) {
adapter = null
super.onDestroyView(view)
}
override fun onSaveViewState(view: View, outState: Bundle) {
super.onSaveViewState(view, outState)
adapter?.onSaveInstanceState(outState)
}
override fun onRestoreViewState(view: View, savedViewState: Bundle) {
super.onRestoreViewState(view, savedViewState)
adapter?.onRestoreInstanceState(savedViewState)
}
/**
* Returns the view holder for the given anime.
*
* @param source used to find holder containing source
* @return the holder of the anime or null if it's not bound.
*/
private fun getHolder(source: CatalogueSource): GlobalAnimeSearchHolder? {
val adapter = adapter ?: return null
adapter.allBoundViewHolders.forEach { holder ->
val item = adapter.getItem(holder.bindingAdapterPosition)
if (item != null && source.id == item.source.id) {
return holder as GlobalAnimeSearchHolder
}
}
return null
}
/**
* Add search result to adapter.
*
* @param searchResult result of search.
*/
fun setItems(searchResult: List<GlobalAnimeSearchItem>) {
if (searchResult.isEmpty() && preferences.searchPinnedSourcesOnly()) {
binding.emptyView.show(R.string.no_pinned_sources)
} else {
binding.emptyView.hide()
}
adapter?.updateDataSet(searchResult)
val progress = searchResult.mapNotNull { it.results }.size.toDouble() / searchResult.size
if (progress < 1) {
binding.progressBar.isVisible = true
binding.progressBar.progress = (progress * 100).toInt()
} else {
binding.progressBar.isVisible = false
}
}
/**
* Called from the presenter when a anime is initialized.
*
* @param anime the initialized anime.
*/
fun onAnimeInitialized(source: CatalogueSource, anime: Anime) {
getHolder(source)?.setImage(anime)
}
/**
* Opens a catalogue with the given search.
*/
override fun onTitleClick(source: CatalogueSource) {
presenter.preferences.lastUsedSource().set(source.id)
router.pushController(BrowseSourceController(source, presenter.query).withFadeTransaction())
}
}

View file

@ -0,0 +1,110 @@
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
import android.view.View
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.databinding.GlobalSearchControllerCardBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.util.system.LocaleHelper
/**
* Holder that binds the [GlobalSearchItem] containing catalogue cards.
*
* @param view view of [GlobalSearchItem]
* @param adapter instance of [GlobalSearchAdapter]
*/
class GlobalAnimeSearchHolder(view: View, val adapter: GlobalAnimeSearchAdapter) :
FlexibleViewHolder(view, adapter) {
private val binding = GlobalSearchControllerCardBinding.bind(view)
/**
* Adapter containing anime from search results.
*/
private val animeAdapter = GlobalAnimeSearchCardAdapter(adapter.controller)
private var lastBoundResults: List<GlobalAnimeSearchCardItem>? = null
init {
// Set layout horizontal.
binding.recycler.layoutManager = LinearLayoutManager(view.context, LinearLayoutManager.HORIZONTAL, false)
binding.recycler.adapter = animeAdapter
binding.titleWrapper.setOnClickListener {
adapter.getItem(bindingAdapterPosition)?.let {
adapter.titleClickListener.onTitleClick(it.source)
}
}
}
/**
* Show the loading of source search result.
*
* @param item item of card.
*/
fun bind(item: GlobalAnimeSearchItem) {
val source = item.source
val results = item.results
val titlePrefix = if (item.highlighted) "" else ""
binding.title.text = titlePrefix + source.name
binding.subtitle.isVisible = source !is LocalSource
binding.subtitle.text = LocaleHelper.getDisplayName(source.lang)
when {
results == null -> {
binding.progress.isVisible = true
showResultsHolder()
}
results.isEmpty() -> {
binding.progress.isVisible = false
showNoResults()
}
else -> {
binding.progress.isVisible = false
showResultsHolder()
}
}
if (results !== lastBoundResults) {
animeAdapter.updateDataSet(results)
lastBoundResults = results
}
}
/**
* Called from the presenter when a anime is initialized.
*
* @param anime the initialized anime.
*/
fun setImage(anime: Anime) {
getHolder(anime)?.setImage(anime)
}
/**
* Returns the view holder for the given anime.
*
* @param anime the anime to find.
* @return the holder of the anime or null if it's not bound.
*/
private fun getHolder(anime: Anime): GlobalAnimeSearchCardHolder? {
animeAdapter.allBoundViewHolders.forEach { holder ->
val item = animeAdapter.getItem(holder.bindingAdapterPosition)
if (item != null && item.anime.id!! == anime.id!!) {
return holder as GlobalAnimeSearchCardHolder
}
}
return null
}
private fun showResultsHolder() {
binding.noResultsFound.isVisible = false
}
private fun showNoResults() {
binding.noResultsFound.isVisible = true
}
}

View file

@ -0,0 +1,71 @@
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource
/**
* Item that contains search result information.
*
* @param source the source for the search results.
* @param results the search results.
* @param highlighted whether this search item should be highlighted/marked in the catalogue search view.
*/
class GlobalAnimeSearchItem(val source: CatalogueSource, val results: List<GlobalAnimeSearchCardItem>?, val highlighted: Boolean = false) :
AbstractFlexibleItem<GlobalAnimeSearchHolder>() {
/**
* Set view.
*
* @return id of view
*/
override fun getLayoutRes(): Int {
return R.layout.global_search_controller_card
}
/**
* Create view holder (see [GlobalSearchAdapter].
*
* @return holder of view.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): GlobalAnimeSearchHolder {
return GlobalAnimeSearchHolder(view, adapter as GlobalAnimeSearchAdapter)
}
/**
* Bind item to view.
*/
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: GlobalAnimeSearchHolder,
position: Int,
payloads: List<Any?>?
) {
holder.bind(this)
}
/**
* Used to check if two items are equal.
*
* @return items are equal?
*/
override fun equals(other: Any?): Boolean {
if (other is GlobalAnimeSearchItem) {
return source.id == other.source.id
}
return false
}
/**
* Return hash code of item.
*
* @return hashcode
*/
override fun hashCode(): Int {
return source.id.toInt()
}
}

View file

@ -0,0 +1,273 @@
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
import android.os.Bundle
import eu.kanade.tachiyomi.data.database.AnimeDatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.database.models.toAnimeInfo
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.AnimesPage
import eu.kanade.tachiyomi.source.model.SAnime
import eu.kanade.tachiyomi.source.model.toSAnime
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
import eu.kanade.tachiyomi.util.lang.runAsObservable
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subjects.PublishSubject
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
/**
* Presenter of [GlobalAnimeSearchController]
* Function calls should be done from here. UI calls should be done from the controller.
*
* @param sourceManager manages the different sources.
* @param db manages the database calls.
* @param preferences manages the preference calls.
*/
open class GlobalAnimeSearchPresenter(
val initialQuery: String? = "",
val initialExtensionFilter: String? = null,
val sourceManager: SourceManager = Injekt.get(),
val db: AnimeDatabaseHelper = Injekt.get(),
val preferences: PreferencesHelper = Injekt.get()
) : BasePresenter<GlobalAnimeSearchController>() {
/**
* Enabled sources.
*/
val sources by lazy { getSourcesToQuery() }
/**
* Fetches the different sources by user settings.
*/
private var fetchSourcesSubscription: Subscription? = null
/**
* Subject which fetches image of given anime.
*/
private val fetchImageSubject = PublishSubject.create<Pair<List<Anime>, Source>>()
/**
* Subscription for fetching images of anime.
*/
private var fetchImageSubscription: Subscription? = null
private val extensionManager: ExtensionManager by injectLazy()
private var extensionFilter: String? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
extensionFilter = savedState?.getString(GlobalAnimeSearchPresenter::extensionFilter.name)
?: initialExtensionFilter
// Perform a search with previous or initial state
search(
savedState?.getString(BrowseSourcePresenter::query.name)
?: initialQuery.orEmpty()
)
}
override fun onDestroy() {
fetchSourcesSubscription?.unsubscribe()
fetchImageSubscription?.unsubscribe()
super.onDestroy()
}
override fun onSave(state: Bundle) {
state.putString(BrowseSourcePresenter::query.name, query)
state.putString(GlobalAnimeSearchPresenter::extensionFilter.name, extensionFilter)
super.onSave(state)
}
/**
* Returns a list of enabled sources ordered by language and name, with pinned catalogues
* prioritized.
*
* @return list containing enabled sources.
*/
protected open fun getEnabledSources(): List<CatalogueSource> {
val languages = preferences.enabledLanguages().get()
val disabledSourceIds = preferences.disabledSources().get()
val pinnedSourceIds = preferences.pinnedSources().get()
return sourceManager.getCatalogueSources()
.filter { it.lang in languages }
.filterNot { it.id.toString() in disabledSourceIds }
.sortedWith(compareBy({ it.id.toString() !in pinnedSourceIds }, { "${it.name.toLowerCase()} (${it.lang})" }))
}
private fun getSourcesToQuery(): List<CatalogueSource> {
val filter = extensionFilter
val enabledSources = getEnabledSources()
var filteredSources: List<CatalogueSource>? = null
if (!filter.isNullOrEmpty()) {
filteredSources = extensionManager.installedExtensions
.filter { it.pkgName == filter }
.flatMap { it.sources }
.filter { it in enabledSources }
.filterIsInstance<CatalogueSource>()
}
if (filteredSources != null && filteredSources.isNotEmpty()) {
return filteredSources
}
val onlyPinnedSources = preferences.searchPinnedSourcesOnly()
val pinnedSourceIds = preferences.pinnedSources().get()
return enabledSources
.filter { if (onlyPinnedSources) it.id.toString() in pinnedSourceIds else true }
}
/**
* Creates a catalogue search item
*/
protected open fun createCatalogueSearchItem(source: CatalogueSource, results: List<GlobalAnimeSearchCardItem>?): GlobalAnimeSearchItem {
return GlobalAnimeSearchItem(source, results)
}
/**
* Initiates a search for anime per catalogue.
*
* @param query query on which to search.
*/
fun search(query: String) {
// Return if there's nothing to do
if (this.query == query) return
// Update query
this.query = query
// Create image fetch subscription
initializeFetchImageSubscription()
// Create items with the initial state
val initialItems = sources.map { createCatalogueSearchItem(it, null) }
var items = initialItems
val pinnedSourceIds = preferences.pinnedSources().get()
fetchSourcesSubscription?.unsubscribe()
fetchSourcesSubscription = Observable.from(sources)
.flatMap(
{ source ->
Observable.defer { source.fetchSearchAnime(1, query, FilterList()) }
.subscribeOn(Schedulers.io())
.onErrorReturn { AnimesPage(emptyList(), false) } // Ignore timeouts or other exceptions
.map { it.animes }
.map { list -> list.map { networkToLocalAnime(it, source.id) } } // Convert to local anime
.doOnNext { fetchImage(it, source) } // Load anime covers
.map { list -> createCatalogueSearchItem(source, list.map { GlobalAnimeSearchCardItem(it) }) }
},
5
)
.observeOn(AndroidSchedulers.mainThread())
// Update matching source with the obtained results
.map { result ->
items
.map { item -> if (item.source == result.source) result else item }
.sortedWith(
compareBy(
// Bubble up sources that actually have results
{ it.results.isNullOrEmpty() },
// Same as initial sort, i.e. pinned first then alphabetically
{ it.source.id.toString() !in pinnedSourceIds },
{ "${it.source.name.toLowerCase()} (${it.source.lang})" }
)
)
}
// Update current state
.doOnNext { items = it }
// Deliver initial state
.startWith(initialItems)
.subscribeLatestCache(
{ view, anime ->
view.setItems(anime)
},
{ _, error ->
Timber.e(error)
}
)
}
/**
* Initialize a list of anime.
*
* @param anime the list of anime to initialize.
*/
private fun fetchImage(anime: List<Anime>, source: Source) {
fetchImageSubject.onNext(Pair(anime, source))
}
/**
* Subscribes to the initializer of anime details and updates the view if needed.
*/
private fun initializeFetchImageSubscription() {
fetchImageSubscription?.unsubscribe()
fetchImageSubscription = fetchImageSubject.observeOn(Schedulers.io())
.flatMap { (first, source) ->
Observable.from(first)
.filter { it.thumbnail_url == null && !it.initialized }
.map { Pair(it, source) }
.concatMap { runAsObservable({ getAnimeDetails(it.first, it.second) }) }
.map { Pair(source as CatalogueSource, it) }
}
.onBackpressureBuffer()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ (source, anime) ->
@Suppress("DEPRECATION")
view?.onAnimeInitialized(source, anime)
},
{ error ->
Timber.e(error)
}
)
}
/**
* Initializes the given anime.
*
* @param anime the anime to initialize.
* @return The initialized anime.
*/
private suspend fun getAnimeDetails(anime: Anime, source: Source): Anime {
val networkAnime = source.getAnimeDetails(anime.toAnimeInfo())
anime.copyFrom(networkAnime.toSAnime())
anime.initialized = true
db.insertAnime(anime).executeAsBlocking()
return anime
}
/**
* Returns a anime from the database for the given anime from network. It creates a new entry
* if the anime is not yet in the database.
*
* @param sAnime the anime from the source.
* @return a anime from the database.
*/
protected open fun networkToLocalAnime(sAnime: SAnime, sourceId: Long): Anime {
var localAnime = db.getAnime(sAnime.url, sourceId).executeAsBlocking()
if (localAnime == null) {
val newAnime = Anime.create(sAnime.url, sAnime.title, sourceId)
newAnime.copyFrom(sAnime)
val result = db.insertAnime(newAnime).executeAsBlocking()
newAnime.id = result.insertedId()
localAnime = newAnime
}
return localAnime
}
}

View file

@ -32,7 +32,7 @@ import eu.kanade.tachiyomi.ui.base.controller.TabbedController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.AnimeController
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.drop
@ -417,7 +417,7 @@ class LibraryController(
when (item.itemId) {
R.id.action_search -> expandActionViewFromInteraction = true
R.id.action_filter -> showSettingsSheet()
R.id.action_update_library -> {
R.id.action_update_animelib -> {
activity?.let {
if (LibraryUpdateService.start(it)) {
it.toast(R.string.updating_library)
@ -487,7 +487,7 @@ class LibraryController(
// Notify the presenter a manga is being opened.
presenter.onOpenManga()
router.pushController(MangaController(manga).withFadeTransaction())
router.pushController(AnimeController(manga).withFadeTransaction())
}
/**

View file

@ -32,7 +32,6 @@ import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.preference.asImmediateFlow
import eu.kanade.tachiyomi.databinding.MainActivityBinding
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
import eu.kanade.tachiyomi.ui.animelib.MoreController
import eu.kanade.tachiyomi.ui.base.activity.BaseViewBindingActivity
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.FabController
@ -45,7 +44,7 @@ import eu.kanade.tachiyomi.ui.browse.BrowseController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.download.DownloadController
import eu.kanade.tachiyomi.ui.library.LibraryController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.AnimeController
import eu.kanade.tachiyomi.ui.more.MoreController
import eu.kanade.tachiyomi.ui.recent.history.HistoryController
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController
@ -69,7 +68,7 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
2 -> R.id.nav_history
3 -> R.id.nav_updates
4 -> R.id.nav_browse
5 -> R.id.nav_animelib
5 -> R.id.nav_library
else -> R.id.nav_library
}
}
@ -148,7 +147,7 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
R.id.nav_history -> setRoot(HistoryController(), id)
R.id.nav_browse -> setRoot(BrowseController(), id)
R.id.nav_more -> setRoot(MoreController(), id)
R.id.nav_animelib -> setRoot(AnimelibController(), id)
R.id.nav_library -> setRoot(AnimelibController(), id)
}
} else if (!isHandlingShortcut) {
when (id) {
@ -284,7 +283,7 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
router.popToRoot()
}
setSelectedNavItem(R.id.nav_library)
router.pushController(RouterTransaction.with(MangaController(extras)))
router.pushController(RouterTransaction.with(AnimeController(extras)))
}
SHORTCUT_DOWNLOADS -> {
if (router.backstackSize > 1) {

View file

@ -19,7 +19,7 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.AnimeController
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.view.setChips
@ -33,10 +33,10 @@ import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
class MangaInfoHeaderAdapter(
private val controller: MangaController,
private val fromSource: Boolean
private val controller: AnimeController,
private val fromSource: Boolean
) :
RecyclerView.Adapter<MangaInfoHeaderAdapter.HeaderViewHolder>() {
RecyclerView.Adapter<AnimeInfoHeaderAdapter.HeaderViewHolder>() {
private val trackManager: TrackManager by injectLazy()

View file

@ -24,7 +24,7 @@ import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.source.browse.ProgressItem
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.AnimeController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.filter
@ -171,7 +171,7 @@ class HistoryController :
override fun onItemClick(position: Int) {
val manga = (adapter?.getItem(position) as? HistoryItem)?.mch?.manga ?: return
router.pushController(MangaController(manga).withFadeTransaction())
router.pushController(AnimeController(manga).withFadeTransaction())
}
override fun removeHistory(manga: Manga, history: History, all: Boolean) {

View file

@ -3,13 +3,13 @@ package eu.kanade.tachiyomi.ui.recent.updates
import android.content.Context
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChaptersAdapter
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseEpisodesAdapter
import eu.kanade.tachiyomi.util.system.getResourceColor
class UpdatesAdapter(
val controller: UpdatesController,
context: Context
) : BaseChaptersAdapter<IFlexible<*>>(controller) {
) : BaseEpisodesAdapter<IFlexible<*>>(controller) {
var readColor = context.getResourceColor(R.attr.colorOnSurface, 0.38f)
var unreadColor = context.getResourceColor(R.attr.colorOnSurface)

View file

@ -20,10 +20,9 @@ import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.databinding.UpdatesControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChaptersAdapter
import eu.kanade.tachiyomi.ui.manga.AnimeController
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseEpisodesAdapter
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.system.notificationManager
import eu.kanade.tachiyomi.util.system.toast
@ -43,7 +42,7 @@ class UpdatesController :
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
FlexibleAdapter.OnUpdateListener,
BaseChaptersAdapter.OnChapterClickListener,
BaseEpisodesAdapter.OnChapterClickListener,
ConfirmDeleteChaptersDialog.Listener,
UpdatesAdapter.OnCoverClickListener {
@ -132,7 +131,7 @@ class UpdatesController :
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_update_library -> updateLibrary()
R.id.action_update_animelib -> updateLibrary()
}
return super.onOptionsItemSelected(item)
@ -287,7 +286,7 @@ class UpdatesController :
}
private fun openManga(chapter: UpdatesItem) {
router.pushController(MangaController(chapter.manga).withFadeTransaction())
router.pushController(AnimeController(chapter.manga).withFadeTransaction())
}
/**

View file

@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.toMangaThumbnail
import eu.kanade.tachiyomi.databinding.UpdatesItemBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterHolder
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseEpisodeHolder
/**
* Holder that contains chapter item
@ -23,7 +23,7 @@ import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterHolder
* @constructor creates a new recent chapter holder.
*/
class UpdatesHolder(private val view: View, private val adapter: UpdatesAdapter) :
BaseChapterHolder(view, adapter) {
BaseEpisodeHolder(view, adapter) {
private val binding = UpdatesItemBinding.bind(view)

View file

@ -7,11 +7,11 @@ import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterItem
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseEpisodeItem
import eu.kanade.tachiyomi.ui.recent.DateSectionItem
class UpdatesItem(chapter: Chapter, val manga: Manga, header: DateSectionItem) :
BaseChapterItem<UpdatesHolder, DateSectionItem>(chapter, header) {
BaseEpisodeItem<UpdatesHolder, DateSectionItem>(chapter, header) {
override fun getLayoutRes(): Int {
return R.layout.updates_item

View file

@ -0,0 +1,46 @@
package eu.kanade.tachiyomi.ui.watcher
import eu.kanade.tachiyomi.data.database.models.Episode
/**
* Load strategy using the source order. This is the default ordering.
*/
class EpisodeLoadBySource {
fun get(allEpisodes: List<Episode>): List<Episode> {
return allEpisodes.sortedByDescending { it.source_order }
}
}
/**
* Load strategy using unique episode numbers with same scanlator preference.
*/
class EpisodeLoadByNumber {
fun get(allEpisodes: List<Episode>, selectedEpisode: Episode): List<Episode> {
val episodes = mutableListOf<Episode>()
val episodesByNumber = allEpisodes.groupBy { it.episode_number }
for ((number, episodesForNumber) in episodesByNumber) {
val preferredEpisode = when {
// Make sure the selected episode is always present
number == selectedEpisode.episode_number -> selectedEpisode
// If there is only one episode for this number, use it
episodesForNumber.size == 1 -> episodesForNumber.first()
// Prefer a episode of the same scanlator as the selected
else ->
episodesForNumber.find { it.scanlator == selectedEpisode.scanlator }
?: episodesForNumber.first()
}
episodes.add(preferredEpisode)
}
return episodes.sortedBy { it.episode_number }
}
}
/**
* Load strategy using the episode upload date. This ordering ignores scanlators
*/
class EpisodeLoadByUploadDate {
fun get(allEpisodes: List<Episode>): List<Episode> {
return allEpisodes.sortedBy { it.date_upload }
}
}

View file

@ -0,0 +1,54 @@
package eu.kanade.tachiyomi.ui.watcher
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.text.Spannable
import android.text.SpannableString
import android.text.style.ScaleXSpan
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import eu.kanade.tachiyomi.widget.OutlineSpan
/**
* Page indicator found at the bottom of the watcher
*/
class PageIndicatorTextView(
context: Context,
attrs: AttributeSet? = null
) : AppCompatTextView(context, attrs) {
init {
setTextColor(fillColor)
}
@SuppressLint("SetTextI18n")
override fun setText(text: CharSequence?, type: BufferType?) {
// Add spaces at the start & end of the text, otherwise the stroke is cut-off because it's
// not taken into account when measuring the text (view's padding doesn't help).
val currText = " $text "
// Also add a bit of spacing between each character, as the stroke overlaps them
val finalText = SpannableString(currText.asIterable().joinToString("\u00A0")).apply {
// Apply text outline
setSpan(spanOutline, 1, length - 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
for (i in 1..lastIndex step 2) {
setSpan(ScaleXSpan(0.2f), i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
super.setText(finalText, BufferType.SPANNABLE)
}
private companion object {
private val fillColor = Color.rgb(235, 235, 235)
private val strokeColor = Color.rgb(45, 45, 45)
// A span object with text outlining properties
val spanOutline = OutlineSpan(
strokeColor = strokeColor,
strokeWidth = 4f
)
}
}

View file

@ -0,0 +1,36 @@
package eu.kanade.tachiyomi.ui.watcher
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PorterDuff
import android.util.AttributeSet
import android.view.View
import androidx.core.graphics.toXfermode
class WatcherColorFilterView(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val colorFilterPaint: Paint = Paint()
fun setFilterColor(color: Int, filterMode: Int) {
colorFilterPaint.color = color
colorFilterPaint.xfermode = when (filterMode) {
1 -> PorterDuff.Mode.MULTIPLY
2 -> PorterDuff.Mode.SCREEN
3 -> PorterDuff.Mode.OVERLAY
4 -> PorterDuff.Mode.LIGHTEN
5 -> PorterDuff.Mode.DARKEN
else -> PorterDuff.Mode.SRC_OVER
}.toXfermode()
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawPaint(colorFilterPaint)
}
}

View file

@ -0,0 +1,119 @@
package eu.kanade.tachiyomi.ui.watcher
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewPropertyAnimator
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.ui.watcher.viewer.ViewerNavigation
import kotlin.math.abs
class WatcherNavigationOverlayView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {
private var viewPropertyAnimator: ViewPropertyAnimator? = null
private var navigation: ViewerNavigation? = null
fun setNavigation(navigation: ViewerNavigation, showOnStart: Boolean) {
if (!showOnStart && this.navigation == null) {
this.navigation = navigation
isVisible = false
return
}
this.navigation = navigation
invalidate()
if (isVisible) return
viewPropertyAnimator = animate()
.alpha(1f)
.setDuration(FADE_DURATION)
.withStartAction {
isVisible = true
}
.withEndAction {
viewPropertyAnimator = null
}
viewPropertyAnimator?.start()
}
private val regionPaint = Paint()
private val textPaint = Paint().apply {
textAlign = Paint.Align.CENTER
color = Color.WHITE
textSize = 64f
}
private val textBorderPaint = Paint().apply {
textAlign = Paint.Align.CENTER
color = Color.BLACK
textSize = 64f
style = Paint.Style.STROKE
strokeWidth = 8f
}
override fun onDraw(canvas: Canvas?) {
if (navigation == null) return
navigation?.regions?.forEach { region ->
val rect = region.rectF
canvas?.save()
// Scale rect from 1f,1f to screen width and height
canvas?.scale(width.toFloat(), height.toFloat())
regionPaint.color = ContextCompat.getColor(context, region.type.colorRes)
canvas?.drawRect(rect, regionPaint)
canvas?.restore()
// Don't want scale anymore because it messes with drawText
canvas?.save()
// Translate origin to rect start (left, top)
canvas?.translate((width * rect.left), (height * rect.top))
// Calculate center of rect width on screen
val x = width * (abs(rect.left - rect.right) / 2)
// Calculate center of rect height on screen
val y = height * (abs(rect.top - rect.bottom) / 2)
canvas?.drawText(context.getString(region.type.nameRes), x, y, textBorderPaint)
canvas?.drawText(context.getString(region.type.nameRes), x, y, textPaint)
canvas?.restore()
}
}
override fun performClick(): Boolean {
super.performClick()
if (viewPropertyAnimator == null && isVisible) {
viewPropertyAnimator = animate()
.alpha(0f)
.setDuration(FADE_DURATION)
.withEndAction {
isVisible = false
viewPropertyAnimator = null
}
viewPropertyAnimator?.start()
}
return true
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
// Hide overlay if user start tapping or swiping
performClick()
return super.onTouchEvent(event)
}
}
private const val FADE_DURATION = 1000L

View file

@ -0,0 +1,59 @@
package eu.kanade.tachiyomi.ui.watcher
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.WatcherPageSheetBinding
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage
import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog
/**
* Sheet to show when a page is long clicked.
*/
class WatcherPageSheet(
private val activity: WatcherActivity,
private val page: WatcherPage
) : BaseBottomSheetDialog(activity) {
private val binding = WatcherPageSheetBinding.inflate(activity.layoutInflater, null, false)
init {
setContentView(binding.root)
binding.setAsCoverLayout.setOnClickListener { setAsCover() }
binding.shareLayout.setOnClickListener { share() }
binding.saveLayout.setOnClickListener { save() }
}
/**
* Sets the image of this page as the cover of the anime.
*/
private fun setAsCover() {
if (page.status != Page.READY) return
MaterialDialog(activity)
.message(R.string.confirm_set_image_as_cover)
.positiveButton(android.R.string.ok) {
activity.setAsCover(page)
dismiss()
}
.negativeButton(android.R.string.cancel)
.show()
}
/**
* Shares the image of this page with external apps.
*/
private fun share() {
activity.shareImage(page)
dismiss()
}
/**
* Saves the image of this page on external storage.
*/
private fun save() {
activity.saveImage(page)
dismiss()
}
}

View file

@ -0,0 +1,44 @@
package eu.kanade.tachiyomi.ui.watcher
import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.appcompat.widget.AppCompatSeekBar
/**
* Seekbar to show current episode progress.
*/
class WatcherSeekBar @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 watcher.
*/
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 watcher.
*/
override fun onTouchEvent(event: MotionEvent): Boolean {
if (isRTL) {
event.setLocation(width - event.x, event.y)
}
return super.onTouchEvent(event)
}
}

View file

@ -0,0 +1,108 @@
package eu.kanade.tachiyomi.ui.watcher
import android.content.Context
import android.graphics.Bitmap
import androidx.core.app.NotificationCompat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.system.notificationBuilder
import eu.kanade.tachiyomi.util.system.notificationManager
import java.io.File
/**
* Class used to show BigPictureStyle notifications
*/
class SaveImageNotifier(private val context: Context) {
/**
* Notification builder.
*/
private val notificationBuilder = context.notificationBuilder(Notifications.CHANNEL_COMMON)
/**
* Id of the notification.
*/
private val notificationId: Int
get() = Notifications.ID_DOWNLOAD_IMAGE
/**
* Called when image download/copy is complete. This method must be called in a background
* thread.
*
* @param file image file containing downloaded page image.
*/
fun onComplete(file: File) {
val bitmap = GlideApp.with(context)
.asBitmap()
.load(file)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
.submit(720, 1280)
.get()
if (bitmap != null) {
showCompleteNotification(file, bitmap)
} else {
onError(null)
}
}
private fun showCompleteNotification(file: File, image: Bitmap) {
with(notificationBuilder) {
setContentTitle(context.getString(R.string.picture_saved))
setSmallIcon(R.drawable.ic_photo_24dp)
setStyle(NotificationCompat.BigPictureStyle().bigPicture(image))
setLargeIcon(image)
setAutoCancel(true)
// Clear old actions if they exist
clearActions()
setContentIntent(NotificationHandler.openImagePendingActivity(context, file))
// Share action
addAction(
R.drawable.ic_share_24dp,
context.getString(R.string.action_share),
NotificationReceiver.shareImagePendingBroadcast(context, file.absolutePath, notificationId)
)
// Delete action
addAction(
R.drawable.ic_delete_24dp,
context.getString(R.string.action_delete),
NotificationReceiver.deleteImagePendingBroadcast(context, file.absolutePath, notificationId)
)
updateNotification()
}
}
/**
* Clears the notification message.
*/
fun onClear() {
context.notificationManager.cancel(notificationId)
}
private fun updateNotification() {
// Displays the progress bar on notification
context.notificationManager.notify(notificationId, notificationBuilder.build())
}
/**
* Called on error while downloading image.
* @param error string containing error information.
*/
fun onError(error: String?) {
// Create notification
with(notificationBuilder) {
setContentTitle(context.getString(R.string.download_notifier_title_error))
setContentText(error ?: context.getString(R.string.unknown_error))
setSmallIcon(android.R.drawable.ic_menu_report_image)
}
updateNotification()
}
}

View file

@ -21,15 +21,18 @@ import android.widget.SeekBar
import android.widget.Toast
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.setPadding
import androidx.lifecycle.lifecycleScope
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Episode
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.asImmediateFlow
import eu.kanade.tachiyomi.data.preference.toggle
import eu.kanade.tachiyomi.databinding.WatcherActivityBinding
import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity
import eu.kanade.tachiyomi.ui.main.MainActivity
@ -37,12 +40,12 @@ import eu.kanade.tachiyomi.ui.anime.AnimeController
import eu.kanade.tachiyomi.ui.watcher.WatcherPresenter.SetAsCoverResult.AddToLibraryFirst
import eu.kanade.tachiyomi.ui.watcher.WatcherPresenter.SetAsCoverResult.Error
import eu.kanade.tachiyomi.ui.watcher.WatcherPresenter.SetAsCoverResult.Success
import eu.kanade.tachiyomi.ui.watcher.model.WatcherChapter
import eu.kanade.tachiyomi.ui.watcher.model.WatcherEpisode
import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage
import eu.kanade.tachiyomi.ui.watcher.model.ViewerChapters
import eu.kanade.tachiyomi.ui.watcher.model.ViewerEpisodes
import eu.kanade.tachiyomi.ui.watcher.setting.OrientationType
import eu.kanade.tachiyomi.ui.watcher.setting.WatcherSettingsSheet
import eu.kanade.tachiyomi.ui.watcher.setting.WatchingModeType
import eu.kanade.tachiyomi.ui.watcher.setting.ReadingModeType
import eu.kanade.tachiyomi.ui.watcher.viewer.BaseViewer
import eu.kanade.tachiyomi.ui.watcher.viewer.pager.L2RPagerViewer
import eu.kanade.tachiyomi.ui.watcher.viewer.pager.R2LPagerViewer
@ -55,6 +58,7 @@ import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.defaultBar
import eu.kanade.tachiyomi.util.view.hideBar
import eu.kanade.tachiyomi.util.view.isDefaultBar
import eu.kanade.tachiyomi.util.view.setTooltip
import eu.kanade.tachiyomi.util.view.showBar
import eu.kanade.tachiyomi.widget.SimpleAnimationListener
import eu.kanade.tachiyomi.widget.SimpleSeekBarListener
@ -77,7 +81,7 @@ import kotlin.math.abs
class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>() {
companion object {
fun newIntent(context: Context, anime: Anime, episode: Chapter): Intent {
fun newIntent(context: Context, anime: Anime, episode: Episode): Intent {
return Intent(context, WatcherActivity::class.java).apply {
putExtra("anime", anime.id)
putExtra("episode", episode.id)
@ -128,9 +132,9 @@ class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(
when (preferences.watcherTheme().get()) {
0 -> R.style.Theme_Watcher_Light
2 -> R.style.Theme_Watcher_Dark_Grey
else -> R.style.Theme_Watcher_Dark
0 -> R.style.Theme_Reader_Light
2 -> R.style.Theme_Reader_Dark_Grey
else -> R.style.Theme_Reader_Dark
}
)
super.onCreate(savedInstanceState)
@ -214,9 +218,9 @@ class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.watcher, menu)
val isChapterBookmarked = presenter?.getCurrentChapter()?.episode?.bookmark ?: false
menu.findItem(R.id.action_bookmark).isVisible = !isChapterBookmarked
menu.findItem(R.id.action_remove_bookmark).isVisible = isChapterBookmarked
val isEpisodeBookmarked = presenter?.getCurrentEpisode()?.episode?.bookmark ?: false
menu.findItem(R.id.action_bookmark).isVisible = !isEpisodeBookmarked
menu.findItem(R.id.action_remove_bookmark).isVisible = isEpisodeBookmarked
return true
}
@ -228,11 +232,11 @@ class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_bookmark -> {
presenter.bookmarkCurrentChapter(true)
presenter.bookmarkCurrentEpisode(true)
invalidateOptionsMenu()
}
R.id.action_remove_bookmark -> {
presenter.bookmarkCurrentChapter(false)
presenter.bookmarkCurrentEpisode(false)
invalidateOptionsMenu()
}
}
@ -250,10 +254,10 @@ class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_N) {
presenter.loadNextChapter()
presenter.loadNextEpisode()
return true
} else if (keyCode == KeyEvent.KEYCODE_P) {
presenter.loadPreviousChapter()
presenter.loadPreviousEpisode()
return true
}
return super.onKeyUp(keyCode, event)
@ -321,21 +325,21 @@ class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>
}
}
)
binding.leftChapter.setOnClickListener {
binding.leftEpisode.setOnClickListener {
if (viewer != null) {
if (viewer is R2LPagerViewer) {
loadNextChapter()
loadNextEpisode()
} else {
loadPreviousChapter()
loadPreviousEpisode()
}
}
}
binding.rightChapter.setOnClickListener {
binding.rightEpisode.setOnClickListener {
if (viewer != null) {
if (viewer is R2LPagerViewer) {
loadPreviousChapter()
loadPreviousEpisode()
} else {
loadNextChapter()
loadNextEpisode()
}
}
}
@ -347,18 +351,18 @@ class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>
}
private fun initBottomShortcuts() {
// Watching mode
with(binding.actionWatchingMode) {
// Reading mode
with(binding.actionReadingMode) {
setTooltip(R.string.viewer)
setOnClickListener {
val newWatchingMode =
WatchingModeType.getNextWatchingMode(presenter.getAnimeViewer(resolveDefault = false))
presenter.setAnimeViewer(newWatchingMode.prefValue)
val newReadingMode =
ReadingModeType.getNextReadingMode(presenter.getAnimeViewer(resolveDefault = false))
presenter.setAnimeViewer(newReadingMode.prefValue)
menuToggleToast?.cancel()
if (!preferences.showWatchingMode()) {
menuToggleToast = toast(newWatchingMode.stringRes)
if (!preferences.showReadingMode()) {
menuToggleToast = toast(newReadingMode.stringRes)
}
}
}
@ -386,7 +390,7 @@ class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>
setTooltip(R.string.pref_crop_borders)
setOnClickListener {
val isPagerType = WatchingModeType.isPagerType(presenter.getAnimeViewer())
val isPagerType = ReadingModeType.isPagerType(presenter.getAnimeViewer())
if (isPagerType) {
preferences.cropBorders().toggle()
} else {
@ -423,7 +427,7 @@ class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>
}
private fun updateCropBordersShortcut() {
val isPagerType = WatchingModeType.isPagerType(presenter.getAnimeViewer())
val isPagerType = ReadingModeType.isPagerType(presenter.getAnimeViewer())
val enabled = if (isPagerType) {
preferences.cropBorders().get()
} else {
@ -515,14 +519,14 @@ class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>
fun setAnime(anime: Anime) {
val prevViewer = viewer
val viewerMode = WatchingModeType.fromPreference(presenter.getAnimeViewer(resolveDefault = false))
binding.actionWatchingMode.setImageResource(viewerMode.iconRes)
val viewerMode = ReadingModeType.fromPreference(presenter.getAnimeViewer(resolveDefault = false))
binding.actionReadingMode.setImageResource(viewerMode.iconRes)
val newViewer = when (presenter.getAnimeViewer()) {
WatchingModeType.LEFT_TO_RIGHT.prefValue -> L2RPagerViewer(this)
WatchingModeType.VERTICAL.prefValue -> VerticalPagerViewer(this)
WatchingModeType.WEBTOON.prefValue -> WebtoonViewer(this)
WatchingModeType.CONTINUOUS_VERTICAL.prefValue -> WebtoonViewer(this, isContinuous = false)
ReadingModeType.LEFT_TO_RIGHT.prefValue -> L2RPagerViewer(this)
ReadingModeType.VERTICAL.prefValue -> VerticalPagerViewer(this)
ReadingModeType.WEBTOON.prefValue -> WebtoonViewer(this)
ReadingModeType.CONTINUOUS_VERTICAL.prefValue -> WebtoonViewer(this, isContinuous = false)
else -> R2LPagerViewer(this)
}
@ -534,26 +538,26 @@ class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>
viewer = newViewer
binding.viewerContainer.addView(newViewer.getView())
if (preferences.showWatchingMode()) {
showWatchingModeToast(presenter.getAnimeViewer())
if (preferences.showReadingMode()) {
showReadingModeToast(presenter.getAnimeViewer())
}
binding.toolbar.title = anime.title
binding.pageSeekbar.isRTL = newViewer is R2LPagerViewer
if (newViewer is R2LPagerViewer) {
binding.leftChapter.setTooltip(R.string.action_next_episode)
binding.rightChapter.setTooltip(R.string.action_previous_episode)
binding.leftEpisode.setTooltip(R.string.action_next_episode)
binding.rightEpisode.setTooltip(R.string.action_previous_episode)
} else {
binding.leftChapter.setTooltip(R.string.action_previous_episode)
binding.rightChapter.setTooltip(R.string.action_next_episode)
binding.leftEpisode.setTooltip(R.string.action_previous_episode)
binding.rightEpisode.setTooltip(R.string.action_next_episode)
}
binding.pleaseWait.isVisible = true
binding.pleaseWait.startAnimation(AnimationUtils.loadAnimation(this, R.anim.fade_in_long))
}
private fun showWatchingModeToast(mode: Int) {
private fun showReadingModeToast(mode: Int) {
try {
val strings = resources.getStringArray(R.array.viewers_selector)
readingModeToast?.cancel()
@ -564,13 +568,13 @@ class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>
}
/**
* Called from the presenter whenever a new [viewerChapters] have been set. It delegates the
* Called from the presenter whenever a new [viewerEpisodes] have been set. It delegates the
* method to the current viewer, but also set the subtitle on the toolbar.
*/
fun setChapters(viewerChapters: ViewerChapters) {
fun setEpisodes(viewerEpisodes: ViewerEpisodes) {
binding.pleaseWait.isVisible = false
viewer?.setChapters(viewerChapters)
binding.toolbar.subtitle = viewerChapters.currChapter.episode.name
viewer?.setEpisodes(viewerEpisodes)
binding.toolbar.subtitle = viewerEpisodes.currEpisode.episode.name
// Invalidate menu to show proper episode bookmark state
invalidateOptionsMenu()
@ -580,7 +584,7 @@ class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>
* Called from the presenter if the initial load couldn't load the pages of the episode. In
* this case the activity is closed and a toast is shown to the user.
*/
fun setInitialChapterError(error: Throwable) {
fun setInitialEpisodeError(error: Throwable) {
Timber.e(error)
finish()
toast(error.message)
@ -608,8 +612,8 @@ class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>
*/
fun moveToPageIndex(index: Int) {
val viewer = viewer ?: return
val currentChapter = presenter.getCurrentChapter() ?: return
val page = currentChapter.pages?.getOrNull(index) ?: return
val currentEpisode = presenter.getCurrentEpisode() ?: return
val page = currentEpisode.pages?.getOrNull(index) ?: return
viewer.moveToPage(page)
}
@ -617,16 +621,16 @@ class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>
* Tells the presenter to load the next episode and mark it as active. The progress dialog
* should be automatically shown.
*/
private fun loadNextChapter() {
presenter.loadNextChapter()
private fun loadNextEpisode() {
presenter.loadNextEpisode()
}
/**
* Tells the presenter to load the previous episode and mark it as active. The progress dialog
* should be automatically shown.
*/
private fun loadPreviousChapter() {
presenter.loadPreviousChapter()
private fun loadPreviousEpisode() {
presenter.loadPreviousEpisode()
}
/**
@ -667,8 +671,8 @@ class WatcherActivity : BaseRxActivity<WatcherActivityBinding, WatcherPresenter>
* Called from the viewer when the given [episode] should be preloaded. It should be called when
* the viewer is reaching the beginning or end of a episode or the transition page is active.
*/
fun requestPreloadChapter(episode: WatcherChapter) {
presenter.preloadChapter(episode)
fun requestPreloadEpisode(episode: WatcherEpisode) {
presenter.preloadEpisode(episode)
}
/**

View file

@ -0,0 +1,717 @@
package eu.kanade.tachiyomi.ui.watcher
import android.app.Application
import android.os.Bundle
import android.os.Environment
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.watcher.loader.EpisodeLoader
import eu.kanade.tachiyomi.ui.watcher.model.WatcherEpisode
import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage
import eu.kanade.tachiyomi.ui.watcher.model.ViewerEpisodes
import eu.kanade.tachiyomi.util.isLocal
import eu.kanade.tachiyomi.util.lang.byteSize
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.takeBytes
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.updateCoverLastModified
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.util.Date
import java.util.concurrent.TimeUnit
/**
* Presenter used by the activity to perform background operations.
*/
class WatcherPresenter(
private val db: DatabaseHelper = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get()
) : BasePresenter<WatcherActivity>() {
/**
* The anime loaded in the watcher. It can be null when instantiated for a short time.
*/
var anime: Anime? = null
private set
/**
* The episode id of the currently loaded episode. Used to restore from process kill.
*/
private var episodeId = -1L
/**
* The episode loader for the loaded anime. It'll be null until [anime] is set.
*/
private var loader: EpisodeLoader? = null
/**
* Subscription to prevent setting episodes as active from multiple threads.
*/
private var activeEpisodeSubscription: Subscription? = null
/**
* Relay for currently active viewer episodes.
*/
private val viewerEpisodesRelay = BehaviorRelay.create<ViewerEpisodes>()
/**
* Relay used when loading prev/next episode needed to lock the UI (with a dialog).
*/
private val isLoadingAdjacentEpisodeRelay = BehaviorRelay.create<Boolean>()
/**
* Episode list for the active anime. It's retrieved lazily and should be accessed for the first
* time in a background thread to avoid blocking the UI.
*/
private val episodeList by lazy {
val anime = anime!!
val dbEpisodes = db.getEpisodes(anime).executeAsBlocking()
val selectedEpisode = dbEpisodes.find { it.id == episodeId }
?: error("Requested episode of id $episodeId not found in episode list")
val episodesForWatcher =
if (preferences.skipRead() || preferences.skipFiltered()) {
val list = dbEpisodes
.filter {
if (preferences.skipRead() && it.read) {
return@filter false
} else if (preferences.skipFiltered()) {
if (
(anime.readFilter == Anime.SHOW_READ && !it.read) ||
(anime.readFilter == Anime.SHOW_UNREAD && it.read) ||
(
anime.downloadedFilter == Anime.SHOW_DOWNLOADED &&
!downloadManager.isEpisodeDownloaded(it, anime)
) ||
(anime.bookmarkedFilter == Anime.SHOW_BOOKMARKED && !it.bookmark)
) {
return@filter false
}
}
true
}
.toMutableList()
val find = list.find { it.id == episodeId }
if (find == null) {
list.add(selectedEpisode)
}
list
} else {
dbEpisodes
}
when (anime.sorting) {
Anime.SORTING_SOURCE -> EpisodeLoadBySource().get(episodesForWatcher)
Anime.SORTING_NUMBER -> EpisodeLoadByNumber().get(episodesForWatcher, selectedEpisode)
Anime.SORTING_UPLOAD_DATE -> EpisodeLoadByUploadDate().get(episodesForWatcher)
else -> error("Unknown sorting method")
}.map(::WatcherEpisode)
}
private var hasTrackers: Boolean = false
private val checkTrackers: (Anime) -> Unit = { anime ->
val tracks = db.getTracks(anime).executeAsBlocking()
hasTrackers = tracks.size > 0
}
/**
* Called when the presenter is created. It retrieves the saved active episode if the process
* was restored.
*/
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
if (savedState != null) {
episodeId = savedState.getLong(::episodeId.name, -1)
}
}
/**
* Called when the presenter is destroyed. It saves the current progress and cleans up
* references on the currently active episodes.
*/
override fun onDestroy() {
super.onDestroy()
val currentEpisodes = viewerEpisodesRelay.value
if (currentEpisodes != null) {
currentEpisodes.unref()
saveEpisodeProgress(currentEpisodes.currEpisode)
saveEpisodeHistory(currentEpisodes.currEpisode)
}
}
/**
* Called when the presenter instance is being saved. It saves the currently active episode
* id and the last page read.
*/
override fun onSave(state: Bundle) {
super.onSave(state)
val currentEpisode = getCurrentEpisode()
if (currentEpisode != null) {
currentEpisode.requestedPage = currentEpisode.episode.last_page_read
state.putLong(::episodeId.name, currentEpisode.episode.id!!)
}
}
/**
* Called when the user pressed the back button and is going to leave the watcher. Used to
* trigger deletion of the downloaded episodes.
*/
fun onBackPressed() {
deletePendingEpisodes()
}
/**
* Called when the activity is saved and not changing configurations. It updates the database
* to persist the current progress of the active episode.
*/
fun onSaveInstanceStateNonConfigurationChange() {
val currentEpisode = getCurrentEpisode() ?: return
saveEpisodeProgress(currentEpisode)
}
/**
* Whether this presenter is initialized yet.
*/
fun needsInit(): Boolean {
return anime == null
}
/**
* Initializes this presenter with the given [animeId] and [initialEpisodeId]. This method will
* fetch the anime from the database and initialize the initial episode.
*/
fun init(animeId: Long, initialEpisodeId: Long) {
if (!needsInit()) return
db.getAnime(animeId).asRxObservable()
.first()
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { init(it, initialEpisodeId) }
.subscribeFirst(
{ _, _ ->
// Ignore onNext event
},
WatcherActivity::setInitialEpisodeError
)
}
/**
* Initializes this presenter with the given [anime] and [initialEpisodeId]. This method will
* set the episode loader, view subscriptions and trigger an initial load.
*/
private fun init(anime: Anime, initialEpisodeId: Long) {
if (!needsInit()) return
this.anime = anime
if (episodeId == -1L) episodeId = initialEpisodeId
checkTrackers(anime)
val context = Injekt.get<Application>()
val source = sourceManager.getOrStub(anime.source)
loader = EpisodeLoader(context, downloadManager, anime, source)
Observable.just(anime).subscribeLatestCache(WatcherActivity::setAnime)
viewerEpisodesRelay.subscribeLatestCache(WatcherActivity::setEpisodes)
isLoadingAdjacentEpisodeRelay.subscribeLatestCache(WatcherActivity::setProgressDialog)
// Read episodeList from an io thread because it's retrieved lazily and would block main.
activeEpisodeSubscription?.unsubscribe()
activeEpisodeSubscription = Observable
.fromCallable { episodeList.first { episodeId == it.episode.id } }
.flatMap { getLoadObservable(loader!!, it) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ _, _ ->
// Ignore onNext event
},
WatcherActivity::setInitialEpisodeError
)
}
/**
* Returns an observable that loads the given [episode] with this [loader]. This observable
* handles main thread synchronization and updating the currently active episodes on
* [viewerEpisodesRelay], however callers must ensure there won't be more than one
* subscription active by unsubscribing any existing [activeEpisodeSubscription] before.
* Callers must also handle the onError event.
*/
private fun getLoadObservable(
loader: EpisodeLoader,
episode: WatcherEpisode
): Observable<ViewerEpisodes> {
return loader.loadEpisode(episode)
.andThen(
Observable.fromCallable {
val episodePos = episodeList.indexOf(episode)
ViewerEpisodes(
episode,
episodeList.getOrNull(episodePos - 1),
episodeList.getOrNull(episodePos + 1)
)
}
)
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { newEpisodes ->
val oldEpisodes = viewerEpisodesRelay.value
// Add new references first to avoid unnecessary recycling
newEpisodes.ref()
oldEpisodes?.unref()
viewerEpisodesRelay.call(newEpisodes)
}
}
/**
* Called when the user changed to the given [episode] when changing pages from the viewer.
* It's used only to set this episode as active.
*/
private fun loadNewEpisode(episode: WatcherEpisode) {
val loader = loader ?: return
Timber.d("Loading ${episode.episode.url}")
activeEpisodeSubscription?.unsubscribe()
activeEpisodeSubscription = getLoadObservable(loader, episode)
.toCompletable()
.onErrorComplete()
.subscribe()
.also(::add)
}
/**
* Called when the user is going to load the prev/next episode through the menu button. It
* sets the [isLoadingAdjacentEpisodeRelay] that the view uses to prevent any further
* interaction until the episode is loaded.
*/
private fun loadAdjacent(episode: WatcherEpisode) {
val loader = loader ?: return
Timber.d("Loading adjacent ${episode.episode.url}")
activeEpisodeSubscription?.unsubscribe()
activeEpisodeSubscription = getLoadObservable(loader, episode)
.doOnSubscribe { isLoadingAdjacentEpisodeRelay.call(true) }
.doOnUnsubscribe { isLoadingAdjacentEpisodeRelay.call(false) }
.subscribeFirst(
{ view, _ ->
view.moveToPageIndex(0)
},
{ _, _ ->
// Ignore onError event, viewers handle that state
}
)
}
/**
* Called when the viewers decide it's a good time to preload a [episode] and improve the UX so
* that the user doesn't have to wait too long to continue reading.
*/
private fun preload(episode: WatcherEpisode) {
if (episode.state != WatcherEpisode.State.Wait && episode.state !is WatcherEpisode.State.Error) {
return
}
Timber.d("Preloading ${episode.episode.url}")
val loader = loader ?: return
loader.loadEpisode(episode)
.observeOn(AndroidSchedulers.mainThread())
// Update current episodes whenever a episode is preloaded
.doOnCompleted { viewerEpisodesRelay.value?.let(viewerEpisodesRelay::call) }
.onErrorComplete()
.subscribe()
.also(::add)
}
/**
* Called every time a page changes on the watcher. Used to mark the flag of episodes being
* read, update tracking services, enqueue downloaded episode deletion, and updating the active episode if this
* [page]'s episode is different from the currently active.
*/
fun onPageSelected(page: WatcherPage) {
val currentEpisodes = viewerEpisodesRelay.value ?: return
val selectedEpisode = page.episode
// Save last page read and mark as read if needed
selectedEpisode.episode.last_page_read = page.index
val shouldTrack = !preferences.incognitoMode().get() || hasTrackers
if (selectedEpisode.pages?.lastIndex == page.index && shouldTrack) {
selectedEpisode.episode.read = true
updateTrackEpisodeRead(selectedEpisode)
deleteEpisodeIfNeeded(selectedEpisode)
deleteEpisodeFromDownloadQueue(currentEpisodes.currEpisode)
}
if (selectedEpisode != currentEpisodes.currEpisode) {
Timber.d("Setting ${selectedEpisode.episode.url} as active")
onEpisodeChanged(currentEpisodes.currEpisode)
loadNewEpisode(selectedEpisode)
}
}
/**
* Removes [currentEpisode] from download queue
* if setting is enabled and [currentEpisode] is queued for download
*/
private fun deleteEpisodeFromDownloadQueue(currentEpisode: WatcherEpisode) {
downloadManager.getEpisodeDownloadOrNull(currentEpisode.episode)?.let { download ->
downloadManager.deletePendingDownload(download)
}
}
/**
* Determines if deleting option is enabled and nth to last episode actually exists.
* If both conditions are satisfied enqueues episode for delete
* @param currentEpisode current episode, which is going to be marked as read.
*/
private fun deleteEpisodeIfNeeded(currentEpisode: WatcherEpisode) {
// Determine which episode should be deleted and enqueue
val currentEpisodePosition = episodeList.indexOf(currentEpisode)
val removeAfterReadSlots = preferences.removeAfterReadSlots()
val episodeToDelete = episodeList.getOrNull(currentEpisodePosition - removeAfterReadSlots)
// Check if deleting option is enabled and episode exists
if (removeAfterReadSlots != -1 && episodeToDelete != null) {
enqueueDeleteReadEpisodes(episodeToDelete)
}
}
/**
* Called when a episode changed from [fromEpisode] to [toEpisode]. It updates [fromEpisode]
* on the database.
*/
private fun onEpisodeChanged(fromEpisode: WatcherEpisode) {
saveEpisodeProgress(fromEpisode)
saveEpisodeHistory(fromEpisode)
}
/**
* Saves this [episode] progress (last read page and whether it's read).
* If incognito mode isn't on or has at least 1 tracker
*/
private fun saveEpisodeProgress(episode: WatcherEpisode) {
if (!preferences.incognitoMode().get() || hasTrackers) {
db.updateEpisodeProgress(episode.episode).asRxCompletable()
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
}
/**
* Saves this [episode] last read history if incognito mode isn't on.
*/
private fun saveEpisodeHistory(episode: WatcherEpisode) {
if (!preferences.incognitoMode().get()) {
val history = History.create(episode.episode).apply { last_read = Date().time }
db.updateHistoryLastRead(history).asRxCompletable()
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
}
/**
* Called from the activity to preload the given [episode].
*/
fun preloadEpisode(episode: WatcherEpisode) {
preload(episode)
}
/**
* Called from the activity to load and set the next episode as active.
*/
fun loadNextEpisode() {
val nextEpisode = viewerEpisodesRelay.value?.nextEpisode ?: return
loadAdjacent(nextEpisode)
}
/**
* Called from the activity to load and set the previous episode as active.
*/
fun loadPreviousEpisode() {
val prevEpisode = viewerEpisodesRelay.value?.prevEpisode ?: return
loadAdjacent(prevEpisode)
}
/**
* Returns the currently active episode.
*/
fun getCurrentEpisode(): WatcherEpisode? {
return viewerEpisodesRelay.value?.currEpisode
}
/**
* Bookmarks the currently active episode.
*/
fun bookmarkCurrentEpisode(bookmarked: Boolean) {
if (getCurrentEpisode()?.episode == null) {
return
}
val episode = getCurrentEpisode()?.episode!!
episode.bookmark = bookmarked
db.updateEpisodeProgress(episode).executeAsBlocking()
}
/**
* Returns the viewer position used by this anime or the default one.
*/
fun getAnimeViewer(resolveDefault: Boolean = true): Int {
val anime = anime ?: return preferences.defaultViewer()
return if (resolveDefault && anime.viewer == 0) preferences.defaultViewer() else anime.viewer
}
/**
* Updates the viewer position for the open anime.
*/
fun setAnimeViewer(viewer: Int) {
val anime = anime ?: return
anime.viewer = viewer
db.updateAnimeViewer(anime).executeAsBlocking()
Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
val currEpisodes = viewerEpisodesRelay.value
if (currEpisodes != null) {
// Save current page
val currEpisode = currEpisodes.currEpisode
currEpisode.requestedPage = currEpisode.episode.last_page_read
// Emit anime and episodes to the new viewer
view.setAnime(anime)
view.setEpisodes(currEpisodes)
}
})
}
/**
* Saves the image of this [page] in the given [directory] and returns the file location.
*/
private fun saveImage(page: WatcherPage, directory: File, anime: Anime): File {
val stream = page.stream!!
val type = ImageUtil.findImageType(stream) ?: throw Exception("Not an image")
directory.mkdirs()
val episode = page.episode.episode
// Build destination file.
val filenameSuffix = " - ${page.number}.${type.extension}"
val filename = DiskUtil.buildValidFilename(
"${anime.title} - ${episode.name}".takeBytes(MAX_FILE_NAME_BYTES - filenameSuffix.byteSize())
) + filenameSuffix
val destFile = File(directory, filename)
stream().use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
return destFile
}
/**
* Saves the image of this [page] on the pictures directory and notifies the UI of the result.
* There's also a notification to allow sharing the image somewhere else or deleting it.
*/
fun saveImage(page: WatcherPage) {
if (page.status != Page.READY) return
val anime = anime ?: return
val context = Injekt.get<Application>()
val notifier = SaveImageNotifier(context)
notifier.onClear()
// Pictures directory.
val destDir = File(
Environment.getExternalStorageDirectory().absolutePath +
File.separator + Environment.DIRECTORY_PICTURES +
File.separator + context.getString(R.string.app_name)
)
// Copy file in background.
Observable.fromCallable { saveImage(page, destDir, anime) }
.doOnNext { file ->
DiskUtil.scanMedia(context, file)
notifier.onComplete(file)
}
.doOnError { notifier.onError(it.message) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) },
{ view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) }
)
}
/**
* Shares the image of this [page] and notifies the UI with the path of the file to share.
* The image must be first copied to the internal partition because there are many possible
* formats it can come from, like a zipped episode, in which case it's not possible to directly
* get a path to the file and it has to be decompresssed somewhere first. Only the last shared
* image will be kept so it won't be taking lots of internal disk space.
*/
fun shareImage(page: WatcherPage) {
if (page.status != Page.READY) return
val anime = anime ?: return
val context = Injekt.get<Application>()
val destDir = File(context.cacheDir, "shared_image")
Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file
.map { saveImage(page, destDir, anime) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.onShareImageResult(file, page) },
{ _, _ -> /* Empty */ }
)
}
/**
* Sets the image of this [page] as cover and notifies the UI of the result.
*/
fun setAsCover(page: WatcherPage) {
if (page.status != Page.READY) return
val anime = anime ?: return
val stream = page.stream ?: return
Observable
.fromCallable {
if (anime.isLocal()) {
val context = Injekt.get<Application>()
LocalSource.updateCover(context, anime, stream())
anime.updateCoverLastModified(db)
R.string.cover_updated
SetAsCoverResult.Success
} else {
if (anime.favorite) {
coverCache.setCustomCoverToCache(anime, stream())
anime.updateCoverLastModified(db)
SetAsCoverResult.Success
} else {
SetAsCoverResult.AddToLibraryFirst
}
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, result -> view.onSetAsCoverResult(result) },
{ view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) }
)
}
/**
* Results of the set as cover feature.
*/
enum class SetAsCoverResult {
Success, AddToLibraryFirst, Error
}
/**
* Results of the save image feature.
*/
sealed class SaveImageResult {
class Success(val file: File) : SaveImageResult()
class Error(val error: Throwable) : SaveImageResult()
}
/**
* Starts the service that updates the last episode read in sync services. This operation
* will run in a background thread and errors are ignored.
*/
private fun updateTrackEpisodeRead(watcherEpisode: WatcherEpisode) {
if (!preferences.autoUpdateTrack()) return
val anime = anime ?: return
val episodeRead = watcherEpisode.episode.episode_number.toInt()
val trackManager = Injekt.get<TrackManager>()
launchIO {
db.getTracks(anime).executeAsBlocking()
.mapNotNull { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged && episodeRead > track.last_episode_read) {
track.last_episode_read = episodeRead
// We want these to execute even if the presenter is destroyed and leaks
// for a while. The view can still be garbage collected.
async {
runCatching {
service.update(track)
db.insertTrack(track).executeAsBlocking()
}
}
} else {
null
}
}
.awaitAll()
.mapNotNull { it.exceptionOrNull() }
.forEach { Timber.w(it) }
}
}
/**
* Enqueues this [episode] to be deleted when [deletePendingEpisodes] is called. The download
* manager handles persisting it across process deaths.
*/
private fun enqueueDeleteReadEpisodes(episode: WatcherEpisode) {
if (!episode.episode.read) return
val anime = anime ?: return
launchIO {
downloadManager.enqueueDeleteEpisodes(listOf(episode.episode), anime)
}
}
/**
* Deletes all the pending episodes. This operation will run in a background thread and errors
* are ignored.
*/
private fun deletePendingEpisodes() {
launchIO {
downloadManager.deletePendingEpisodes()
}
}
companion object {
// Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8)
private const val MAX_FILE_NAME_BYTES = 250
}
}

View file

@ -0,0 +1,40 @@
package eu.kanade.tachiyomi.ui.watcher.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.system.ImageUtil
import rx.Observable
import java.io.File
import java.io.FileInputStream
/**
* Loader used to load a episode 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<WatcherPage>> {
return file.listFiles()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, file ->
val streamFn = { FileInputStream(file) }
WatcherPage(i).apply {
stream = streamFn
status = Page.READY
}
}
.let { Observable.just(it) }
}
/**
* Returns an observable that emits a ready state.
*/
override fun getPage(page: WatcherPage): Observable<Int> {
return Observable.just(Page.READY)
}
}

View file

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

View file

@ -0,0 +1,93 @@
package eu.kanade.tachiyomi.ui.watcher.loader
import android.content.Context
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Anime
import eu.kanade.tachiyomi.data.download.AnimeDownloadManager
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.AnimeHttpSource
import eu.kanade.tachiyomi.ui.watcher.model.WatcherEpisode
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 episode.
*/
class EpisodeLoader(
private val context: Context,
private val downloadManager: AnimeDownloadManager,
private val anime: Anime,
private val source: Source
) {
/**
* Returns a completable that assigns the page loader and loads the its pages. It just
* completes if the episode is already loaded.
*/
fun loadEpisode(episode: WatcherEpisode): Completable {
if (episodeIsReady(episode)) {
return Completable.complete()
}
return Observable.just(episode)
.doOnNext { episode.state = WatcherEpisode.State.Loading }
.observeOn(Schedulers.io())
.flatMap { watcherEpisode ->
Timber.d("Loading pages for ${episode.episode.name}")
val loader = getPageLoader(watcherEpisode)
episode.pageLoader = loader
loader.getPages().take(1).doOnNext { pages ->
pages.forEach { it.episode = episode }
}
}
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { pages ->
if (pages.isEmpty()) {
throw Exception(context.getString(R.string.page_list_empty_error))
}
episode.state = WatcherEpisode.State.Loaded(pages)
// If the episode is partially read, set the starting page to the last the user read
// otherwise use the requested page.
if (!episode.episode.read) {
episode.requestedPage = episode.episode.last_page_read
}
}
.toCompletable()
.doOnError { episode.state = WatcherEpisode.State.Error(it) }
}
/**
* Checks [episode] to be loaded based on present pages and loader in addition to state.
*/
private fun episodeIsReady(episode: WatcherEpisode): Boolean {
return episode.state is WatcherEpisode.State.Loaded && episode.pageLoader != null
}
/**
* Returns the page loader to use for this [episode].
*/
private fun getPageLoader(episode: WatcherEpisode): PageLoader {
val isDownloaded = downloadManager.isEpisodeDownloaded(episode.episode, anime, true)
return when {
isDownloaded -> DownloadPageLoader(episode, anime, source, downloadManager)
source is AnimeHttpSource -> HttpPageLoader(episode, source)
source is LocalSource -> source.getFormat(episode.episode).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(context.getString(R.string.loader_not_implemented_error))
}
}
}

View file

@ -0,0 +1,55 @@
package eu.kanade.tachiyomi.ui.watcher.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage
import eu.kanade.tachiyomi.util.storage.EpubFile
import rx.Observable
import java.io.File
/**
* Loader used to load a episode 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<WatcherPage>> {
return epub.getImagesFromPages()
.mapIndexed { i, path ->
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) }
WatcherPage(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: WatcherPage): Observable<Int> {
return Observable.just(
if (isRecycled) {
Page.ERROR
} else {
Page.READY
}
)
}
}

View file

@ -0,0 +1,244 @@
package eu.kanade.tachiyomi.ui.watcher.loader
import eu.kanade.tachiyomi.data.cache.EpisodeCache
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.AnimeHttpSource
import eu.kanade.tachiyomi.ui.watcher.model.WatcherEpisode
import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage
import eu.kanade.tachiyomi.util.lang.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
import kotlin.math.min
/**
* Loader used to load episodes from an online source.
*/
class HttpPageLoader(
private val episode: WatcherEpisode,
private val source: AnimeHttpSource,
private val episodeCache: EpisodeCache = 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()
private val preloadSize = 4
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 episodes to allow a faster reopen
val pages = episode.pages
if (pages != null) {
Completable
.fromAction {
// Convert to pages without watcher information
val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) }
episodeCache.putPageListToCache(episode.episode, pagesToSave)
}
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
}
/**
* Returns an observable with the page list for a episode. It tries to return the page list from
* the local cache, otherwise fallbacks to network.
*/
override fun getPages(): Observable<List<WatcherPage>> {
return episodeCache
.getPageListFromCache(episode.episode)
.onErrorResumeNext { source.fetchPageListAnime(episode.episode) }
.map { pages ->
pages.mapIndexed { index, page ->
// Don't trust sources and use our own indexing
WatcherPage(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: WatcherPage): Observable<Int> {
return Observable.defer {
val imageUrl = page.imageUrl
// Check if the image has been deleted
if (page.status == Page.READY && imageUrl != null && !episodeCache.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)
val queuedPages = mutableListOf<PriorityPage>()
if (page.status == Page.QUEUE) {
queuedPages += PriorityPage(page, 1).also { queue.offer(it) }
}
queuedPages += preloadNextPages(page, preloadSize)
statusSubject.startWith(page.status)
.doOnUnsubscribe {
queuedPages.forEach {
if (it.page.status == Page.QUEUE) {
queue.remove(it)
}
}
}
}
.subscribeOn(Schedulers.io())
.unsubscribeOn(Schedulers.io())
}
/**
* Preloads the given [amount] of pages after the [currentPage] with a lower priority.
* @return a list of [PriorityPage] that were added to the [queue]
*/
private fun preloadNextPages(currentPage: WatcherPage, amount: Int): List<PriorityPage> {
val pageIndex = currentPage.index
val pages = currentPage.episode.pages ?: return emptyList()
if (pageIndex == pages.lastIndex) return emptyList()
return pages
.subList(pageIndex + 1, min(pageIndex + 1 + amount, pages.size))
.mapNotNull {
if (it.status == Page.QUEUE) {
PriorityPage(it, 0).apply { queue.offer(this) }
} else null
}
}
/**
* Retries a page. This method is only called from user interaction on the viewer.
*/
override fun retryPage(page: WatcherPage) {
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 class PriorityPage(
val page: WatcherPage,
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 AnimeHttpSource.fetchImageFromCacheThenNet(page: WatcherPage): Observable<WatcherPage> {
return if (page.imageUrl.isNullOrEmpty()) {
getImageUrl(page).flatMap { getCachedImage(it) }
} else {
getCachedImage(page)
}
}
private fun AnimeHttpSource.getImageUrl(page: WatcherPage): Observable<WatcherPage> {
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 episode or fallbacks to
* network and copies it to the cache calling [cacheImage].
*
* @param page the page.
*/
private fun AnimeHttpSource.getCachedImage(page: WatcherPage): Observable<WatcherPage> {
val imageUrl = page.imageUrl ?: return Observable.just(page)
return Observable.just(page)
.flatMap {
if (!episodeCache.isImageInCache(imageUrl)) {
cacheImage(page)
} else {
Observable.just(page)
}
}
.doOnNext {
page.stream = { episodeCache.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 [EpisodeCache].
*
* @param page the page.
*/
private fun AnimeHttpSource.cacheImage(page: WatcherPage): Observable<WatcherPage> {
page.status = Page.DOWNLOAD_IMAGE
return fetchImage(page)
.doOnNext { episodeCache.putImageToCache(page.imageUrl!!, it) }
.map { page }
}
}

View file

@ -0,0 +1,45 @@
package eu.kanade.tachiyomi.ui.watcher.loader
import androidx.annotation.CallSuper
import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage
import rx.Observable
/**
* A loader used to load pages into the watcher. 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 episode. Only the first emission
* will be used.
*/
abstract fun getPages(): Observable<List<WatcherPage>>
/**
* Returns an observable that should inform of the progress of the page (see the Page class
* for the available states)
*/
abstract fun getPage(page: WatcherPage): 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: WatcherPage) {}
}

View file

@ -0,0 +1,88 @@
package eu.kanade.tachiyomi.ui.watcher.loader
import com.github.junrar.Archive
import com.github.junrar.rarfile.FileHeader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.system.ImageUtil
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 episode 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<WatcherPage>> {
return archive.fileHeaders
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.mapIndexed { i, header ->
val streamFn = { getStream(header) }
WatcherPage(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: WatcherPage): Observable<Int> {
return Observable.just(
if (isRecycled) {
Page.ERROR
} else {
Page.READY
}
)
}
/**
* Returns an input stream for the given [header].
*/
private fun getStream(header: FileHeader): InputStream {
val pipeIn = PipedInputStream()
val pipeOut = PipedOutputStream(pipeIn)
pool.execute {
try {
pipeOut.use {
archive.extractFile(header, it)
}
} catch (e: Exception) {
}
}
return pipeIn
}
}

View file

@ -0,0 +1,65 @@
package eu.kanade.tachiyomi.ui.watcher.loader
import android.os.Build
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.system.ImageUtil
import rx.Observable
import java.io.File
import java.nio.charset.StandardCharsets
import java.util.zip.ZipFile
/**
* Loader used to load a episode from a .zip or .cbz file.
*/
class ZipPageLoader(file: File) : PageLoader() {
/**
* The zip file to load pages from.
*/
private val zip = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
ZipFile(file, StandardCharsets.ISO_8859_1)
} else {
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<WatcherPage>> {
return zip.entries().toList()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, entry ->
val streamFn = { zip.getInputStream(entry) }
WatcherPage(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: WatcherPage): Observable<Int> {
return Observable.just(
if (isRecycled) {
Page.ERROR
} else {
Page.READY
}
)
}
}

View file

@ -0,0 +1,35 @@
package eu.kanade.tachiyomi.ui.watcher.model
sealed class EpisodeTransition {
abstract val from: WatcherEpisode
abstract val to: WatcherEpisode?
class Prev(
override val from: WatcherEpisode,
override val to: WatcherEpisode?
) : EpisodeTransition()
class Next(
override val from: WatcherEpisode,
override val to: WatcherEpisode?
) : EpisodeTransition()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is EpisodeTransition) 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.episode.url}, to=${to?.episode?.url})"
}
}

View file

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.ui.watcher.model
class InsertPage(val parent: WatcherPage) : WatcherPage(parent.index, parent.url, parent.imageUrl) {
override var episode: WatcherEpisode = parent.episode
init {
stream = parent.stream
}
}

View file

@ -0,0 +1,20 @@
package eu.kanade.tachiyomi.ui.watcher.model
data class ViewerEpisodes(
val currEpisode: WatcherEpisode,
val prevEpisode: WatcherEpisode?,
val nextEpisode: WatcherEpisode?
) {
fun ref() {
currEpisode.ref()
prevEpisode?.ref()
nextEpisode?.ref()
}
fun unref() {
currEpisode.unref()
prevEpisode?.unref()
nextEpisode?.unref()
}
}

View file

@ -0,0 +1,53 @@
package eu.kanade.tachiyomi.ui.watcher.model
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.database.models.Episode
import eu.kanade.tachiyomi.ui.watcher.loader.PageLoader
import timber.log.Timber
data class WatcherEpisode(val episode: Episode) {
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<WatcherPage>?
get() = (state as? State.Loaded)?.pages
var pageLoader: PageLoader? = null
var requestedPage: Int = 0
var references = 0
private set
fun ref() {
references++
}
fun unref() {
references--
if (references == 0) {
if (pageLoader != null) {
Timber.d("Recycling episode ${episode.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<WatcherPage>) : State()
}
}

View file

@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.ui.watcher.model
import eu.kanade.tachiyomi.source.model.Page
import java.io.InputStream
open class WatcherPage(
index: Int,
url: String = "",
imageUrl: String? = null,
var stream: (() -> InputStream)? = null
) : Page(index, url, imageUrl, null) {
open lateinit var episode: WatcherEpisode
}

View file

@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.ui.watcher.setting
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.content.res.Resources
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.lang.next
enum class OrientationType(val prefValue: Int, val flag: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) {
FREE(1, ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, R.string.rotation_free, R.drawable.ic_screen_rotation_24dp),
LOCKED_PORTRAIT(2, ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT, R.string.rotation_lock, R.drawable.ic_screen_lock_rotation_24dp),
LOCKED_LANDSCAPE(2, ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, R.string.rotation_lock, R.drawable.ic_screen_lock_rotation_24dp),
PORTRAIT(3, ActivityInfo.SCREEN_ORIENTATION_PORTRAIT, R.string.rotation_force_portrait, R.drawable.ic_screen_lock_portrait_24dp),
LANDSCAPE(4, ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, R.string.rotation_force_landscape, R.drawable.ic_screen_lock_landscape_24dp);
companion object {
fun fromPreference(preference: Int, resources: Resources): OrientationType = when (preference) {
2 -> {
val currentOrientation = resources.configuration.orientation
if (currentOrientation == Configuration.ORIENTATION_PORTRAIT) {
LOCKED_PORTRAIT
} else {
LOCKED_LANDSCAPE
}
}
3 -> PORTRAIT
4 -> LANDSCAPE
else -> FREE
}
fun getNextOrientation(preference: Int, resources: Resources): OrientationType {
val current = if (preference == 2) {
// Avoid issue due to 2 types having the same prefValue
LOCKED_LANDSCAPE
} else {
fromPreference(preference, resources)
}
return current.next()
}
}
}

View file

@ -0,0 +1,245 @@
package eu.kanade.tachiyomi.ui.watcher.setting
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.SeekBar
import androidx.annotation.ColorInt
import androidx.core.graphics.alpha
import androidx.core.graphics.blue
import androidx.core.graphics.green
import androidx.core.graphics.red
import androidx.core.widget.NestedScrollView
import androidx.lifecycle.lifecycleScope
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.WatcherColorFilterSettingsBinding
import eu.kanade.tachiyomi.ui.watcher.WatcherActivity
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
import eu.kanade.tachiyomi.widget.SimpleSeekBarListener
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import uy.kohesive.injekt.injectLazy
/**
* Color filter sheet to toggle custom filter and brightness overlay.
*/
class WatcherColorFilterSettings @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
NestedScrollView(context, attrs) {
private val preferences: PreferencesHelper by injectLazy()
private val binding = WatcherColorFilterSettingsBinding.inflate(LayoutInflater.from(context), this, false)
init {
addView(binding.root)
preferences.colorFilter().asFlow()
.onEach { setColorFilter(it) }
.launchIn((context as WatcherActivity).lifecycleScope)
preferences.colorFilterMode().asFlow()
.onEach { setColorFilter(preferences.colorFilter().get()) }
.launchIn(context.lifecycleScope)
preferences.customBrightness().asFlow()
.onEach { setCustomBrightness(it) }
.launchIn(context.lifecycleScope)
// Get color and update values
val color = preferences.colorFilterValue().get()
val brightness = preferences.customBrightnessValue().get()
val argb = setValues(color)
// Set brightness value
binding.txtBrightnessSeekbarValue.text = brightness.toString()
binding.brightnessSeekbar.progress = brightness
// Initialize seekBar progress
binding.seekbarColorFilterAlpha.progress = argb[0]
binding.seekbarColorFilterRed.progress = argb[1]
binding.seekbarColorFilterGreen.progress = argb[2]
binding.seekbarColorFilterBlue.progress = argb[3]
// Set listeners
binding.switchColorFilter.isChecked = preferences.colorFilter().get()
binding.switchColorFilter.setOnCheckedChangeListener { _, isChecked ->
preferences.colorFilter().set(isChecked)
}
binding.customBrightness.isChecked = preferences.customBrightness().get()
binding.customBrightness.setOnCheckedChangeListener { _, isChecked ->
preferences.customBrightness().set(isChecked)
}
binding.colorFilterMode.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
preferences.colorFilterMode().set(position)
}
binding.colorFilterMode.setSelection(preferences.colorFilterMode().get(), false)
binding.seekbarColorFilterAlpha.setOnSeekBarChangeListener(
object : SimpleSeekBarListener() {
override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) {
if (fromUser) {
setColorValue(value, ALPHA_MASK, 24)
}
}
}
)
binding.seekbarColorFilterRed.setOnSeekBarChangeListener(
object : SimpleSeekBarListener() {
override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) {
if (fromUser) {
setColorValue(value, RED_MASK, 16)
}
}
}
)
binding.seekbarColorFilterGreen.setOnSeekBarChangeListener(
object : SimpleSeekBarListener() {
override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) {
if (fromUser) {
setColorValue(value, GREEN_MASK, 8)
}
}
}
)
binding.seekbarColorFilterBlue.setOnSeekBarChangeListener(
object : SimpleSeekBarListener() {
override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) {
if (fromUser) {
setColorValue(value, BLUE_MASK, 0)
}
}
}
)
binding.brightnessSeekbar.setOnSeekBarChangeListener(
object : SimpleSeekBarListener() {
override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) {
if (fromUser) {
preferences.customBrightnessValue().set(value)
}
}
}
)
}
/**
* Set enabled status of seekBars belonging to color filter
* @param enabled determines if seekBar gets enabled
*/
private fun setColorFilterSeekBar(enabled: Boolean) {
binding.seekbarColorFilterRed.isEnabled = enabled
binding.seekbarColorFilterGreen.isEnabled = enabled
binding.seekbarColorFilterBlue.isEnabled = enabled
binding.seekbarColorFilterAlpha.isEnabled = enabled
}
/**
* Set enabled status of seekBars belonging to custom brightness
* @param enabled value which determines if seekBar gets enabled
*/
private fun setCustomBrightnessSeekBar(enabled: Boolean) {
binding.brightnessSeekbar.isEnabled = enabled
}
/**
* Set the text value's of color filter
* @param color integer containing color information
*/
fun setValues(color: Int): Array<Int> {
val alpha = color.alpha
val red = color.red
val green = color.green
val blue = color.blue
// Initialize values
binding.txtColorFilterAlphaValue.text = "$alpha"
binding.txtColorFilterRedValue.text = "$red"
binding.txtColorFilterGreenValue.text = "$green"
binding.txtColorFilterBlueValue.text = "$blue"
return arrayOf(alpha, red, green, blue)
}
/**
* Manages the custom brightness value subscription
* @param enabled determines if the subscription get (un)subscribed
*/
private fun setCustomBrightness(enabled: Boolean) {
if (enabled) {
preferences.customBrightnessValue().asFlow()
.sample(100)
.onEach { setCustomBrightnessValue(it) }
.launchIn((context as WatcherActivity).lifecycleScope)
} else {
setCustomBrightnessValue(0, true)
}
setCustomBrightnessSeekBar(enabled)
}
/**
* Sets the brightness of the screen. Range is [-75, 100].
* From -75 to -1 a semi-transparent black view is shown at the top with the minimum brightness.
* From 1 to 100 it sets that value as brightness.
* 0 sets system brightness and hides the overlay.
*/
private fun setCustomBrightnessValue(value: Int, isDisabled: Boolean = false) {
if (!isDisabled) {
binding.txtBrightnessSeekbarValue.text = value.toString()
}
}
/**
* Manages the color filter value subscription
* @param enabled determines if the subscription get (un)subscribed
*/
private fun setColorFilter(enabled: Boolean) {
if (enabled) {
preferences.colorFilterValue().asFlow()
.sample(100)
.onEach { setColorFilterValue(it) }
.launchIn((context as WatcherActivity).lifecycleScope)
}
setColorFilterSeekBar(enabled)
}
/**
* Sets the color filter overlay of the screen. Determined by HEX of integer
* @param color hex of color.
*/
private fun setColorFilterValue(@ColorInt color: Int) {
setValues(color)
}
/**
* Updates the color value in preference
* @param color value of color range [0,255]
* @param mask contains hex mask of chosen color
* @param bitShift amounts of bits that gets shifted to receive value
*/
fun setColorValue(color: Int, mask: Long, bitShift: Int) {
val currentColor = preferences.colorFilterValue().get()
val updatedColor = (color shl bitShift) or (currentColor and mask.inv().toInt())
preferences.colorFilterValue().set(updatedColor)
}
private companion object {
/** Integer mask of alpha value **/
const val ALPHA_MASK: Long = 0xFF000000
/** Integer mask of red value **/
const val RED_MASK: Long = 0x00FF0000
/** Integer mask of green value **/
const val GREEN_MASK: Long = 0x0000FF00
/** Integer mask of blue value **/
const val BLUE_MASK: Long = 0x000000FF
}
}

View file

@ -0,0 +1,50 @@
package eu.kanade.tachiyomi.ui.watcher.setting
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import androidx.core.view.isVisible
import androidx.core.widget.NestedScrollView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.WatcherGeneralSettingsBinding
import eu.kanade.tachiyomi.ui.watcher.WatcherActivity
import eu.kanade.tachiyomi.util.preference.bindToPreference
import uy.kohesive.injekt.injectLazy
/**
* Sheet to show watcher and viewer preferences.
*/
class WatcherGeneralSettings @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
NestedScrollView(context, attrs) {
private val preferences: PreferencesHelper by injectLazy()
private val binding = WatcherGeneralSettingsBinding.inflate(LayoutInflater.from(context), this, false)
init {
addView(binding.root)
initGeneralPreferences()
}
/**
* Init general watcher preferences.
*/
private fun initGeneralPreferences() {
binding.rotationMode.bindToPreference(preferences.rotation(), 1)
binding.backgroundColor.bindToIntPreference(preferences.watcherTheme(), R.array.watcher_themes_values)
binding.showPageNumber.bindToPreference(preferences.showPageNumber())
binding.fullscreen.bindToPreference(preferences.fullscreen())
binding.keepscreen.bindToPreference(preferences.keepScreenOn())
binding.longTap.bindToPreference(preferences.readWithLongTap())
binding.alwaysShowEpisodeTransition.bindToPreference(preferences.alwaysShowEpisodeTransition())
binding.pageTransitions.bindToPreference(preferences.pageTransitions())
// If the preference is explicitly disabled, that means the setting was configured since there is a cutout
if ((context as WatcherActivity).hasCutout || !preferences.cutoutShort().get()) {
binding.cutoutShort.isVisible = true
binding.cutoutShort.bindToPreference(preferences.cutoutShort())
}
}
}

View file

@ -0,0 +1,104 @@
package eu.kanade.tachiyomi.ui.watcher.setting
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import androidx.core.view.isVisible
import androidx.core.widget.NestedScrollView
import androidx.lifecycle.lifecycleScope
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.asImmediateFlow
import eu.kanade.tachiyomi.databinding.WatcherReadingModeSettingsBinding
import eu.kanade.tachiyomi.ui.watcher.WatcherActivity
import eu.kanade.tachiyomi.ui.watcher.viewer.pager.PagerViewer
import eu.kanade.tachiyomi.ui.watcher.viewer.webtoon.WebtoonViewer
import eu.kanade.tachiyomi.util.preference.bindToPreference
import kotlinx.coroutines.flow.launchIn
import uy.kohesive.injekt.injectLazy
/**
* Sheet to show watcher and viewer preferences.
*/
class WatcherReadingModeSettings @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
NestedScrollView(context, attrs) {
private val preferences: PreferencesHelper by injectLazy()
private val binding = WatcherReadingModeSettingsBinding.inflate(LayoutInflater.from(context), this, false)
init {
addView(binding.root)
initGeneralPreferences()
when ((context as WatcherActivity).viewer) {
is PagerViewer -> initPagerPreferences()
is WebtoonViewer -> initWebtoonPreferences()
}
}
/**
* Init general watcher preferences.
*/
private fun initGeneralPreferences() {
binding.viewer.onItemSelectedListener = { position ->
(context as WatcherActivity).presenter.setAnimeViewer(position)
val animeViewer = (context as WatcherActivity).presenter.getAnimeViewer()
if (animeViewer == ReadingModeType.WEBTOON.prefValue || animeViewer == ReadingModeType.CONTINUOUS_VERTICAL.prefValue) {
initWebtoonPreferences()
} else {
initPagerPreferences()
}
}
binding.viewer.setSelection((context as WatcherActivity).presenter.anime?.viewer ?: 0)
}
/**
* Init the preferences for the pager watcher.
*/
private fun initPagerPreferences() {
binding.webtoonPrefsGroup.root.isVisible = false
binding.pagerPrefsGroup.root.isVisible = true
binding.pagerPrefsGroup.tappingPrefsGroup.isVisible = preferences.readWithTapping().get()
binding.pagerPrefsGroup.tappingInverted.bindToPreference(preferences.pagerNavInverted())
binding.pagerPrefsGroup.pagerNav.bindToPreference(preferences.navigationModePager())
binding.pagerPrefsGroup.scaleType.bindToPreference(preferences.imageScaleType(), 1)
binding.pagerPrefsGroup.zoomStart.bindToPreference(preferences.zoomStart(), 1)
binding.pagerPrefsGroup.cropBorders.bindToPreference(preferences.cropBorders())
// Makes so that dual page invert gets hidden away when turning of dual page split
binding.pagerPrefsGroup.dualPageSplit.bindToPreference(preferences.dualPageSplitPaged())
preferences.dualPageSplitPaged()
.asImmediateFlow { binding.pagerPrefsGroup.dualPageInvert.isVisible = it }
.launchIn((context as WatcherActivity).lifecycleScope)
binding.pagerPrefsGroup.dualPageInvert.bindToPreference(preferences.dualPageInvertPaged())
}
/**
* Init the preferences for the webtoon watcher.
*/
private fun initWebtoonPreferences() {
binding.pagerPrefsGroup.root.isVisible = false
binding.webtoonPrefsGroup.root.isVisible = true
binding.webtoonPrefsGroup.tappingPrefsGroup.isVisible = preferences.readWithTapping().get()
binding.webtoonPrefsGroup.tappingInverted.bindToPreference(preferences.webtoonNavInverted())
binding.webtoonPrefsGroup.webtoonNav.bindToPreference(preferences.navigationModeWebtoon())
binding.webtoonPrefsGroup.cropBordersWebtoon.bindToPreference(preferences.cropBordersWebtoon())
binding.webtoonPrefsGroup.webtoonSidePadding.bindToIntPreference(preferences.webtoonSidePadding(), R.array.webtoon_side_padding_values)
// Makes so that dual page invert gets hidden away when turning of dual page split
binding.webtoonPrefsGroup.dualPageSplit.bindToPreference(preferences.dualPageSplitWebtoon())
preferences.dualPageSplitWebtoon()
.asImmediateFlow { binding.webtoonPrefsGroup.dualPageInvert.isVisible = it }
.launchIn((context as WatcherActivity).lifecycleScope)
binding.webtoonPrefsGroup.dualPageInvert.bindToPreference(preferences.dualPageInvertWebtoon())
}
}

View file

@ -0,0 +1,63 @@
package eu.kanade.tachiyomi.ui.watcher.setting
import android.view.ViewGroup
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.tabs.TabLayout
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.watcher.WatcherActivity
import eu.kanade.tachiyomi.widget.SimpleTabSelectedListener
import eu.kanade.tachiyomi.widget.sheet.TabbedBottomSheetDialog
class WatcherSettingsSheet(
private val activity: WatcherActivity,
showColorFilterSettings: Boolean = false,
) : TabbedBottomSheetDialog(activity) {
private val readingModeSettings = WatcherReadingModeSettings(activity)
private val generalSettings = WatcherGeneralSettings(activity)
private val colorFilterSettings = WatcherColorFilterSettings(activity)
private val sheetBackgroundDim = window?.attributes?.dimAmount ?: 0.25f
init {
val sheetBehavior = BottomSheetBehavior.from(binding.root.parent as ViewGroup)
sheetBehavior.isFitToContents = false
sheetBehavior.halfExpandedRatio = 0.5f
val filterTabIndex = getTabViews().indexOf(colorFilterSettings)
binding.tabs.addOnTabSelectedListener(object : SimpleTabSelectedListener() {
override fun onTabSelected(tab: TabLayout.Tab?) {
val isFilterTab = tab?.position == filterTabIndex
// Remove dimmed backdrop so color filter changes can be previewed
window?.setDimAmount(if (isFilterTab) 0f else sheetBackgroundDim)
// Hide toolbars
if (activity.menuVisible != !isFilterTab) {
activity.setMenuVisibility(!isFilterTab)
}
// Partially collapse the sheet for better preview
if (isFilterTab) {
sheetBehavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
}
}
})
if (showColorFilterSettings) {
binding.tabs.getTabAt(filterTabIndex)?.select()
}
}
override fun getTabViews() = listOf(
readingModeSettings,
generalSettings,
colorFilterSettings,
)
override fun getTabTitles() = listOf(
R.string.pref_category_reading_mode,
R.string.pref_category_general,
R.string.custom_filter,
)
}

View file

@ -0,0 +1,30 @@
package eu.kanade.tachiyomi.ui.watcher.setting
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.lang.next
enum class ReadingModeType(val prefValue: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) {
DEFAULT(0, R.string.default_viewer, R.drawable.ic_watcher_default_24dp),
LEFT_TO_RIGHT(1, R.string.left_to_right_viewer, R.drawable.ic_watcher_ltr_24dp),
RIGHT_TO_LEFT(2, R.string.right_to_left_viewer, R.drawable.ic_watcher_rtl_24dp),
VERTICAL(3, R.string.vertical_viewer, R.drawable.ic_watcher_vertical_24dp),
WEBTOON(4, R.string.webtoon_viewer, R.drawable.ic_watcher_webtoon_24dp),
CONTINUOUS_VERTICAL(5, R.string.vertical_plus_viewer, R.drawable.ic_watcher_continuous_vertical_24dp),
;
companion object {
fun fromPreference(preference: Int): ReadingModeType = values().find { it.prefValue == preference } ?: DEFAULT
fun getNextReadingMode(preference: Int): ReadingModeType {
val current = fromPreference(preference)
return current.next()
}
fun isPagerType(preference: Int): Boolean {
val mode = fromPreference(preference)
return mode == LEFT_TO_RIGHT || mode == RIGHT_TO_LEFT || mode == VERTICAL
}
}
}

View file

@ -0,0 +1,45 @@
package eu.kanade.tachiyomi.ui.watcher.viewer
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage
import eu.kanade.tachiyomi.ui.watcher.model.ViewerEpisodes
/**
* Interface for implementing a viewer.
*/
interface BaseViewer {
/**
* Returns the view this viewer uses.
*/
fun getView(): View
/**
* Destroys this viewer. Called when leaving the watcher or swapping viewers.
*/
fun destroy() {}
/**
* Tells this viewer to set the given [episodes] as active.
*/
fun setEpisodes(episodes: ViewerEpisodes)
/**
* Tells this viewer to move to the given [page].
*/
fun moveToPage(page: WatcherPage)
/**
* Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise.
*/
fun handleKeyEvent(event: KeyEvent): Boolean
/**
* Called from the containing activity when a generic motion [event] is received. It should
* return true if the event was handled, false otherwise.
*/
fun handleGenericMotionEvent(event: MotionEvent): Boolean
}

View file

@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.ui.watcher.viewer
import android.content.Context
import android.os.Handler
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ViewConfiguration
import kotlin.math.abs
/**
* 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 (abs(ev.rawX - downX) > slop || abs(ev.rawY - downY) > slop) {
handler.removeCallbacks(longTapFn)
}
}
MotionEvent.ACTION_UP -> {
lastUp = ev.eventTime
handler.removeCallbacks(longTapFn)
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_DOWN -> {
handler.removeCallbacks(longTapFn)
}
}
return super.onTouchEvent(ev)
}
/**
* Custom listener to also include a long tap confirmed
*/
open class Listener : SimpleOnGestureListener() {
/**
* Notified when a long tap occurs with the initial on down [ev] that triggered it.
*/
open fun onLongTapConfirmed(ev: MotionEvent) {
}
}
}

View file

@ -0,0 +1,45 @@
package eu.kanade.tachiyomi.ui.watcher.viewer
import eu.kanade.tachiyomi.data.database.models.Episode
import eu.kanade.tachiyomi.ui.watcher.model.WatcherEpisode
import kotlin.math.floor
private val pattern = Regex("""\d+""")
fun hasMissingEpisodes(higherWatcherEpisode: WatcherEpisode?, lowerWatcherEpisode: WatcherEpisode?): Boolean {
if (higherWatcherEpisode == null || lowerWatcherEpisode == null) return false
return hasMissingEpisodes(higherWatcherEpisode.episode, lowerWatcherEpisode.episode)
}
fun hasMissingEpisodes(higherEpisode: Episode?, lowerEpisode: Episode?): Boolean {
if (higherEpisode == null || lowerEpisode == null) return false
// Check if name contains a number that is potential episode number
if (!pattern.containsMatchIn(higherEpisode.name) || !pattern.containsMatchIn(lowerEpisode.name)) return false
// Check if potential episode number was recognized as episode number
if (!higherEpisode.isRecognizedNumber || !lowerEpisode.isRecognizedNumber) return false
return hasMissingEpisodes(higherEpisode.episode_number, lowerEpisode.episode_number)
}
fun hasMissingEpisodes(higherEpisodeNumber: Float, lowerEpisodeNumber: Float): Boolean {
if (higherEpisodeNumber < 0f || lowerEpisodeNumber < 0f) return false
return calculateEpisodeDifference(higherEpisodeNumber, lowerEpisodeNumber) > 0f
}
fun calculateEpisodeDifference(higherWatcherEpisode: WatcherEpisode?, lowerWatcherEpisode: WatcherEpisode?): Float {
if (higherWatcherEpisode == null || lowerWatcherEpisode == null) return 0f
return calculateEpisodeDifference(higherWatcherEpisode.episode, lowerWatcherEpisode.episode)
}
fun calculateEpisodeDifference(higherEpisode: Episode?, lowerEpisode: Episode?): Float {
if (higherEpisode == null || lowerEpisode == null) return 0f
// Check if name contains a number that is potential episode number
if (!pattern.containsMatchIn(higherEpisode.name) || !pattern.containsMatchIn(lowerEpisode.name)) return 0f
// Check if potential episode number was recognized as episode number
if (!higherEpisode.isRecognizedNumber || !lowerEpisode.isRecognizedNumber) return 0f
return calculateEpisodeDifference(higherEpisode.episode_number, lowerEpisode.episode_number)
}
fun calculateEpisodeDifference(higherEpisodeNumber: Float, lowerEpisodeNumber: Float): Float {
if (higherEpisodeNumber < 0f || lowerEpisodeNumber < 0f) return 0f
return floor(higherEpisodeNumber) - floor(lowerEpisodeNumber) - 1f
}

View file

@ -0,0 +1,215 @@
package eu.kanade.tachiyomi.ui.watcher.viewer
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 androidx.core.animation.doOnCancel
import androidx.core.animation.doOnEnd
import androidx.core.view.isGone
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.getResourceColor
import kotlin.math.min
/**
* 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 WatcherProgressBar @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 = 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 visibility of this view changes.
*/
override fun setVisibility(visibility: Int) {
super.setVisibility(visibility)
val isVisible = visibility == VISIBLE
if (isVisible) {
startAnimation()
} else {
stopAnimation()
}
}
/**
* Starts the rotation animation if needed.
*/
private fun startAnimation() {
if (visibility != VISIBLE || windowVisibility != 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 (isGone) return
if (!animate) {
isVisible = false
} else {
ObjectAnimator.ofFloat(this, "alpha", 1f, 0f).apply {
interpolator = DecelerateInterpolator()
duration = 1000
doOnEnd {
isVisible = false
alpha = 1f
}
doOnCancel {
alpha = 1f
}
start()
}
}
}
/**
* Completes this progress bar and fades out the view.
*/
fun completeAndFadeOut() {
setRealProgress(100)
hide(true)
}
/**
* Set progress of the circular progress bar ensuring a min max range in order to notice the
* rotation animation.
*/
fun setProgress(progress: Int) {
// Scale progress in [10, 95] range
val scaledProgress = 85 * progress / 100 + 10
setRealProgress(scaledProgress)
}
/**
* Sets the real progress of the circular progress bar. Note that if this progres is 0 or
* 100, the rotation animation won't be noticed by the user because nothing changes in the
* canvas.
*/
private fun setRealProgress(progress: Int) {
ValueAnimator.ofFloat(sweepAngle, calcSweepAngleFromProgress(progress)).apply {
interpolator = DecelerateInterpolator()
duration = 250
addUpdateListener { valueAnimator ->
sweepAngle = valueAnimator.animatedValue as Float
invalidate()
}
start()
}
}
}

View file

@ -0,0 +1,105 @@
package eu.kanade.tachiyomi.ui.watcher.viewer
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import androidx.core.text.bold
import androidx.core.text.buildSpannedString
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.WatcherTransitionViewBinding
import eu.kanade.tachiyomi.ui.watcher.model.EpisodeTransition
class WatcherTransitionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
LinearLayout(context, attrs) {
private val binding: WatcherTransitionViewBinding
init {
binding = WatcherTransitionViewBinding.inflate(LayoutInflater.from(context), this, true)
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
fun bind(transition: EpisodeTransition) {
when (transition) {
is EpisodeTransition.Prev -> bindPrevEpisodeTransition(transition)
is EpisodeTransition.Next -> bindNextEpisodeTransition(transition)
}
missingEpisodeWarning(transition)
}
/**
* Binds a previous episode transition on this view and subscribes to the page load status.
*/
private fun bindPrevEpisodeTransition(transition: EpisodeTransition) {
val prevEpisode = transition.to
val hasPrevEpisode = prevEpisode != null
binding.lowerText.isVisible = hasPrevEpisode
if (hasPrevEpisode) {
binding.upperText.textAlignment = TEXT_ALIGNMENT_TEXT_START
binding.upperText.text = buildSpannedString {
bold { append(context.getString(R.string.transition_previous)) }
append("\n${prevEpisode!!.episode.name}")
}
binding.lowerText.text = buildSpannedString {
bold { append(context.getString(R.string.transition_current)) }
append("\n${transition.from.episode.name}")
}
} else {
binding.upperText.textAlignment = TEXT_ALIGNMENT_CENTER
binding.upperText.text = context.getString(R.string.transition_no_previous)
}
}
/**
* Binds a next episode transition on this view and subscribes to the load status.
*/
private fun bindNextEpisodeTransition(transition: EpisodeTransition) {
val nextEpisode = transition.to
val hasNextEpisode = nextEpisode != null
binding.lowerText.isVisible = hasNextEpisode
if (hasNextEpisode) {
binding.upperText.textAlignment = TEXT_ALIGNMENT_TEXT_START
binding.upperText.text = buildSpannedString {
bold { append(context.getString(R.string.transition_finished)) }
append("\n${transition.from.episode.name}")
}
binding.lowerText.text = buildSpannedString {
bold { append(context.getString(R.string.transition_next)) }
append("\n${nextEpisode!!.episode.name}")
}
} else {
binding.upperText.textAlignment = TEXT_ALIGNMENT_CENTER
binding.upperText.text = context.getString(R.string.transition_no_next)
}
}
private fun missingEpisodeWarning(transition: EpisodeTransition) {
if (transition.to == null) {
binding.warning.isVisible = false
return
}
val hasMissingEpisodes = when (transition) {
is EpisodeTransition.Prev -> hasMissingEpisodes(transition.from, transition.to)
is EpisodeTransition.Next -> hasMissingEpisodes(transition.to, transition.from)
}
if (!hasMissingEpisodes) {
binding.warning.isVisible = false
return
}
val episodeDifference = when (transition) {
is EpisodeTransition.Prev -> calculateEpisodeDifference(transition.from, transition.to)
is EpisodeTransition.Next -> calculateEpisodeDifference(transition.to, transition.from)
}
binding.warningText.text = resources.getQuantityString(R.plurals.missing_episodes_warning, episodeDifference.toInt(), episodeDifference.toInt())
binding.warning.isVisible = true
}
}

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