mirror of
https://github.com/aniyomiorg/aniyomi.git
synced 2024-11-28 17:19:00 +03:00
let's hope this compiles!
This commit is contained in:
parent
5971114cf5
commit
1c98f9181d
144 changed files with 14433 additions and 212 deletions
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
88
app/src/main/java/eu/kanade/tachiyomi/source/AnimeSource.kt
Normal file
88
app/src/main/java/eu/kanade/tachiyomi/source/AnimeSource.kt
Normal 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"
|
|
@ -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
|
||||
}
|
||||
}
|
324
app/src/main/java/eu/kanade/tachiyomi/source/LocalAnimeSource.kt
Normal file
324
app/src/main/java/eu/kanade/tachiyomi/source/LocalAnimeSource.kt
Normal 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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
1089
app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeController.kt
Normal file
1089
app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeController.kt
Normal file
File diff suppressed because it is too large
Load diff
797
app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimePresenter.kt
Normal file
797
app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimePresenter.kt
Normal 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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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) })
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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?
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -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) {}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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})"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in a new issue