Add genre filter for catalogue (#428)

* Add genre filter for catalogue

* Implement genre filter for batoto

* hardcode filters for sources

* swtich filter id to string

* reset filters when switching sources

* Add filter support to mangafox

* Catalogue changes

* Indefinite snackbar on error, use plain subscriptions in catalogue presenter
This commit is contained in:
Robin Appelman 2016-08-28 22:59:00 +02:00 committed by inorichi
parent 4171e87b4b
commit 2fb3b50535
21 changed files with 484 additions and 251 deletions

View File

@ -47,5 +47,4 @@ interface Source {
* @param page the page. * @param page the page.
*/ */
fun fetchImage(page: Page): Observable<Page> fun fetchImage(page: Page): Observable<Page>
} }

View File

@ -58,6 +58,11 @@ abstract class OnlineSource(context: Context) : Source {
*/ */
val headers by lazy { headersBuilder().build() } val headers by lazy { headersBuilder().build() }
/**
* Genre filters.
*/
val filters by lazy { getFilterList() }
/** /**
* Default network client for doing requests. * Default network client for doing requests.
*/ */
@ -126,11 +131,11 @@ abstract class OnlineSource(context: Context) : Source {
* the current page and the next page url. * the current page and the next page url.
* @param query the search query. * @param query the search query.
*/ */
open fun fetchSearchManga(page: MangasPage, query: String): Observable<MangasPage> = client open fun fetchSearchManga(page: MangasPage, query: String, filters: List<Filter>): Observable<MangasPage> = client
.newCall(searchMangaRequest(page, query)) .newCall(searchMangaRequest(page, query, filters))
.asObservable() .asObservable()
.map { response -> .map { response ->
searchMangaParse(response, page, query) searchMangaParse(response, page, query, filters)
page page
} }
@ -141,9 +146,9 @@ abstract class OnlineSource(context: Context) : Source {
* @param page the page object. * @param page the page object.
* @param query the search query. * @param query the search query.
*/ */
open protected fun searchMangaRequest(page: MangasPage, query: String): Request { open protected fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
if (page.page == 1) { if (page.page == 1) {
page.url = searchMangaInitialUrl(query) page.url = searchMangaInitialUrl(query, filters)
} }
return GET(page.url, headers) return GET(page.url, headers)
} }
@ -153,7 +158,7 @@ abstract class OnlineSource(context: Context) : Source {
* *
* @param query the search query. * @param query the search query.
*/ */
abstract protected fun searchMangaInitialUrl(query: String): String abstract protected fun searchMangaInitialUrl(query: String, filters: List<Filter>): String
/** /**
* Parse the response from the site. It should add a list of manga and the absolute url to the * Parse the response from the site. It should add a list of manga and the absolute url to the
@ -163,7 +168,7 @@ abstract class OnlineSource(context: Context) : Source {
* @param page the page object to be filled. * @param page the page object to be filled.
* @param query the search query. * @param query the search query.
*/ */
abstract protected fun searchMangaParse(response: Response, page: MangasPage, query: String) abstract protected fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>)
/** /**
* Returns an observable with the updated details for a manga. Normally it's not needed to * Returns an observable with the updated details for a manga. Normally it's not needed to
@ -428,4 +433,7 @@ abstract class OnlineSource(context: Context) : Source {
} }
data class Filter(val id: String, val name: String)
open fun getFilterList(): List<Filter> = emptyList()
} }

View File

@ -64,7 +64,7 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
* @param page the page object to be filled. * @param page the page object to be filled.
* @param query the search query. * @param query the search query.
*/ */
override fun searchMangaParse(response: Response, page: MangasPage, query: String) { override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
val document = response.asJsoup() val document = response.asJsoup()
for (element in document.select(searchMangaSelector())) { for (element in document.select(searchMangaSelector())) {
Manga.create(id).apply { Manga.create(id).apply {
@ -179,5 +179,4 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
* @param document the parsed document. * @param document the parsed document.
*/ */
abstract protected fun imageUrlParse(document: Document): String abstract protected fun imageUrlParse(document: Document): String
} }

View File

@ -5,6 +5,7 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.GET import eu.kanade.tachiyomi.data.network.GET
import eu.kanade.tachiyomi.data.network.POST import eu.kanade.tachiyomi.data.network.POST
import eu.kanade.tachiyomi.data.source.Source
import eu.kanade.tachiyomi.data.source.getLanguages import eu.kanade.tachiyomi.data.source.getLanguages
import eu.kanade.tachiyomi.data.source.model.MangasPage import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
@ -14,6 +15,7 @@ import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import rx.Observable
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@ -68,9 +70,9 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
} }
} }
override fun searchMangaRequest(page: MangasPage, query: String): Request { override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
if (page.page == 1) { if (page.page == 1) {
page.url = searchMangaInitialUrl(query) page.url = searchMangaInitialUrl(query, filters)
} }
return when (map.search.method?.toLowerCase()) { return when (map.search.method?.toLowerCase()) {
"post" -> POST(page.url, headers, map.search.createForm()) "post" -> POST(page.url, headers, map.search.createForm())
@ -78,9 +80,9 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
} }
} }
override fun searchMangaInitialUrl(query: String) = map.search.url.replace("\$query", query) override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = map.search.url.replace("\$query", query)
override fun searchMangaParse(response: Response, page: MangasPage, query: String) { override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
val document = response.asJsoup() val document = response.asJsoup()
for (element in document.select(map.search.manga_css)) { for (element in document.select(map.search.manga_css)) {
Manga.create(id).apply { Manga.create(id).apply {
@ -184,5 +186,4 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
throw Exception("image_regex and image_css are null") throw Exception("image_regex and image_css are null")
} }
} }
} }

View File

@ -84,9 +84,21 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
override fun popularMangaNextPageSelector() = "#show_more_row" override fun popularMangaNextPageSelector() = "#show_more_row"
override fun searchMangaInitialUrl(query: String) = "$baseUrl/search_ajax?name=${Uri.encode(query)}&p=1" override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/search_ajax?name=${Uri.encode(query)}&order_cond=views&order=desc&p=1&genre_cond=and&genres=${getFilterParams(filters)}"
override fun searchMangaParse(response: Response, page: MangasPage, query: String) { private fun getFilterParams(filters: List<Filter>): String = filters
.map {
";i" + it.id
}.joinToString()
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
if (page.page == 1) {
page.url = searchMangaInitialUrl(query, filters)
}
return GET(page.url, headers)
}
override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
val document = response.asJsoup() val document = response.asJsoup()
for (element in document.select(searchMangaSelector())) { for (element in document.select(searchMangaSelector())) {
Manga.create(id).apply { Manga.create(id).apply {
@ -96,7 +108,7 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
} }
page.nextPageUrl = document.select(searchMangaNextPageSelector()).first()?.let { page.nextPageUrl = document.select(searchMangaNextPageSelector()).first()?.let {
"$baseUrl/search_ajax?name=${Uri.encode(query)}&p=${page.page + 1}" "$baseUrl/search_ajax?name=${Uri.encode(query)}&p=${page.page + 1}&order_cond=views&order=desc&genre_cond=and&genres=" + getFilterParams(filters)
} }
} }
@ -264,4 +276,48 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
} }
} }
// [...document.querySelectorAll("#advanced_options div.genre_buttons")].map((el,i) => {
// const onClick=el.getAttribute('onclick');const id=onClick.substr(14,onClick.length-16);return `Filter("${id}", "${el.textContent.trim()}")`
// }).join(',\n')
// on https://bato.to/search
override fun getFilterList(): List<Filter> = listOf(
Filter("40", "4-Koma"),
Filter("1", "Action"),
Filter("2", "Adventure"),
Filter("39", "Award Winning"),
Filter("3", "Comedy"),
Filter("41", "Cooking"),
Filter("9", "Doujinshi"),
Filter("10", "Drama"),
Filter("12", "Ecchi"),
Filter("13", "Fantasy"),
Filter("15", "Gender Bender"),
Filter("17", "Harem"),
Filter("20", "Historical"),
Filter("22", "Horror"),
Filter("34", "Josei"),
Filter("27", "Martial Arts"),
Filter("30", "Mecha"),
Filter("42", "Medical"),
Filter("37", "Music"),
Filter("4", "Mystery"),
Filter("38", "Oneshot"),
Filter("5", "Psychological"),
Filter("6", "Romance"),
Filter("7", "School Life"),
Filter("8", "Sci-fi"),
Filter("32", "Seinen"),
Filter("35", "Shoujo"),
Filter("16", "Shoujo Ai"),
Filter("33", "Shounen"),
Filter("19", "Shounen Ai"),
Filter("21", "Slice of Life"),
Filter("23", "Smut"),
Filter("25", "Sports"),
Filter("26", "Supernatural"),
Filter("28", "Tragedy"),
Filter("36", "Webtoon"),
Filter("29", "Yaoi"),
Filter("31", "Yuri")
)
} }

View File

@ -42,22 +42,34 @@ class Kissmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
override fun popularMangaNextPageSelector() = "li > a:contains( Next)" override fun popularMangaNextPageSelector() = "li > a:contains( Next)"
override fun searchMangaRequest(page: MangasPage, query: String): Request { override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
if (page.page == 1) { if (page.page == 1) {
page.url = searchMangaInitialUrl(query) page.url = searchMangaInitialUrl(query, filters)
} }
val form = FormBody.Builder().apply { val form = FormBody.Builder().apply {
add("authorArtist", "") add("authorArtist", "")
add("mangaName", query) add("mangaName", query)
add("status", "") add("status", "")
add("genres", "")
}.build()
return POST(page.url, headers, form)
} }
override fun searchMangaInitialUrl(query: String) = "$baseUrl/AdvanceSearch" val filterIndexes = filters.map { it.id.toInt() }
val maxFilterIndex = filterIndexes.max()
if (maxFilterIndex !== null) {
for (i in 0..maxFilterIndex) {
form.add("genres", if (filterIndexes.contains(i)) {
"1"
} else {
"0"
})
}
}
return POST(page.url, headers, form.build())
}
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/AdvanceSearch"
override fun searchMangaSelector() = popularMangaSelector() override fun searchMangaSelector() = popularMangaSelector()
@ -109,10 +121,59 @@ class Kissmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
} }
// Not used // Not used
override fun pageListParse(document: Document, pages: MutableList<Page>) {} override fun pageListParse(document: Document, pages: MutableList<Page>) {
}
override fun imageUrlRequest(page: Page) = GET(page.url) override fun imageUrlRequest(page: Page) = GET(page.url)
override fun imageUrlParse(document: Document) = "" override fun imageUrlParse(document: Document) = ""
// $("select[name=\"genres\"]").map((i,el) => `Filter("${i}", "${$(el).next().text().trim()}")`).get().join(',\n')
// on http://kissmanga.com/AdvanceSearch
override fun getFilterList(): List<Filter> = listOf(
Filter("0", "Action"),
Filter("1", "Adult"),
Filter("2", "Adventure"),
Filter("3", "Comedy"),
Filter("4", "Comic"),
Filter("5", "Cooking"),
Filter("6", "Doujinshi"),
Filter("7", "Drama"),
Filter("8", "Ecchi"),
Filter("9", "Fantasy"),
Filter("10", "Gender Bender"),
Filter("11", "Harem"),
Filter("12", "Historical"),
Filter("13", "Horror"),
Filter("14", "Josei"),
Filter("15", "Lolicon"),
Filter("16", "Manga"),
Filter("17", "Manhua"),
Filter("18", "Manhwa"),
Filter("19", "Martial Arts"),
Filter("20", "Mature"),
Filter("21", "Mecha"),
Filter("22", "Medical"),
Filter("23", "Music"),
Filter("24", "Mystery"),
Filter("25", "One shot"),
Filter("26", "Psychological"),
Filter("27", "Romance"),
Filter("28", "School Life"),
Filter("29", "Sci-fi"),
Filter("30", "Seinen"),
Filter("31", "Shotacon"),
Filter("32", "Shoujo"),
Filter("33", "Shoujo Ai"),
Filter("34", "Shounen"),
Filter("35", "Shounen Ai"),
Filter("36", "Slice of Life"),
Filter("37", "Smut"),
Filter("38", "Sports"),
Filter("39", "Supernatural"),
Filter("40", "Tragedy"),
Filter("41", "Webtoon"),
Filter("42", "Yaoi"),
Filter("43", "Yuri")
)
} }

View File

@ -36,8 +36,8 @@ class Mangafox(context: Context, override val id: Int) : ParsedOnlineSource(cont
override fun popularMangaNextPageSelector() = "a:has(span.next)" override fun popularMangaNextPageSelector() = "a:has(span.next)"
override fun searchMangaInitialUrl(query: String) = override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/search.php?name_method=cw&advopts=1&order=za&sort=views&name=$query&page=1" "$baseUrl/search.php?name_method=cw&advopts=1&order=za&sort=views&name=$query&page=1&${filters.map { it.id + "=1" }.joinToString("&")}"
override fun searchMangaSelector() = "table#listing > tbody > tr:gt(0)" override fun searchMangaSelector() = "table#listing > tbody > tr:gt(0)"
@ -118,4 +118,43 @@ class Mangafox(context: Context, override val id: Int) : ParsedOnlineSource(cont
override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src") override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src")
// $('select.genres').map((i,el)=>`Filter("${$(el).attr('name')}", "${$(el).next().text().trim()}")`).get().join(',\n')
// on http://kissmanga.com/AdvanceSearch
override fun getFilterList(): List<Filter> = listOf(
Filter("genres[Action]", "Action"),
Filter("genres[Adult]", "Adult"),
Filter("genres[Adventure]", "Adventure"),
Filter("genres[Comedy]", "Comedy"),
Filter("genres[Doujinshi]", "Doujinshi"),
Filter("genres[Drama]", "Drama"),
Filter("genres[Ecchi]", "Ecchi"),
Filter("genres[Fantasy]", "Fantasy"),
Filter("genres[Gender Bender]", "Gender Bender"),
Filter("genres[Harem]", "Harem"),
Filter("genres[Historical]", "Historical"),
Filter("genres[Horror]", "Horror"),
Filter("genres[Josei]", "Josei"),
Filter("genres[Martial Arts]", "Martial Arts"),
Filter("genres[Mature]", "Mature"),
Filter("genres[Mecha]", "Mecha"),
Filter("genres[Mystery]", "Mystery"),
Filter("genres[One Shot]", "One Shot"),
Filter("genres[Psychological]", "Psychological"),
Filter("genres[Romance]", "Romance"),
Filter("genres[School Life]", "School Life"),
Filter("genres[Sci-fi]", "Sci-fi"),
Filter("genres[Seinen]", "Seinen"),
Filter("genres[Shoujo]", "Shoujo"),
Filter("genres[Shoujo Ai]", "Shoujo Ai"),
Filter("genres[Shounen]", "Shounen"),
Filter("genres[Shounen Ai]", "Shounen Ai"),
Filter("genres[Slice of Life]", "Slice of Life"),
Filter("genres[Smut]", "Smut"),
Filter("genres[Sports]", "Sports"),
Filter("genres[Supernatural]", "Supernatural"),
Filter("genres[Tragedy]", "Tragedy"),
Filter("genres[Webtoons]", "Webtoons"),
Filter("genres[Yaoi]", "Yaoi"),
Filter("genres[Yuri]", "Yuri")
)
} }

View File

@ -34,7 +34,7 @@ class Mangahere(context: Context, override val id: Int) : ParsedOnlineSource(con
override fun popularMangaNextPageSelector() = "div.next-page > a.next" override fun popularMangaNextPageSelector() = "div.next-page > a.next"
override fun searchMangaInitialUrl(query: String) = override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/search.php?name=$query&page=1&sort=views&order=za" "$baseUrl/search.php?name=$query&page=1&sort=views&order=za"
override fun searchMangaSelector() = "div.result_search > dl:has(dt)" override fun searchMangaSelector() = "div.result_search > dl:has(dt)"

View File

@ -47,7 +47,7 @@ class Mangasee(context: Context, override val id: Int) : ParsedOnlineSource(cont
override fun popularMangaNextPageSelector() = "ul.pagination > li > a:contains(Next)" override fun popularMangaNextPageSelector() = "ul.pagination > li > a:contains(Next)"
override fun searchMangaInitialUrl(query: String) = override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/advanced-search/result.php?sortBy=alphabet&direction=ASC&textOnly=no&resPerPage=20&page=1&seriesName=$query" "$baseUrl/advanced-search/result.php?sortBy=alphabet&direction=ASC&textOnly=no&resPerPage=20&page=1&seriesName=$query"
override fun searchMangaSelector() = "div.row > div > div > div > h1" override fun searchMangaSelector() = "div.row > div > div > div > h1"

View File

@ -6,8 +6,10 @@ import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.POST import eu.kanade.tachiyomi.data.network.POST
import eu.kanade.tachiyomi.data.source.EN import eu.kanade.tachiyomi.data.source.EN
import eu.kanade.tachiyomi.data.source.Language import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.Source
import eu.kanade.tachiyomi.data.source.model.MangasPage import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@ -38,16 +40,16 @@ class Readmangatoday(context: Context, override val id: Int) : ParsedOnlineSourc
override fun popularMangaNextPageSelector() = "div.hot-manga > ul.pagination > li > a:contains(»)" override fun popularMangaNextPageSelector() = "div.hot-manga > ul.pagination > li > a:contains(»)"
override fun searchMangaInitialUrl(query: String) = override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/search" "$baseUrl/search"
override fun searchMangaRequest(page: MangasPage, query: String): Request { override fun searchMangaRequest(page: MangasPage, query: String, filters: List<OnlineSource.Filter>): Request {
if (page.page == 1) { if (page.page == 1) {
page.url = searchMangaInitialUrl(query) page.url = searchMangaInitialUrl(query, filters)
} }
var builder = okhttp3.FormBody.Builder() val builder = okhttp3.FormBody.Builder()
builder.add("query", query) builder.add("query", query)
return POST(page.url, headers, builder.build()) return POST(page.url, headers, builder.build())

View File

@ -36,7 +36,7 @@ class WieManga(context: Context, override val id: Int) : ParsedOnlineSource(cont
override fun popularMangaNextPageSelector() = null override fun popularMangaNextPageSelector() = null
override fun searchMangaInitialUrl(query: String) = "$baseUrl/search/?wd=$query" override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/search/?wd=$query"
override fun searchMangaSelector() = ".searchresult td > div" override fun searchMangaSelector() = ".searchresult td > div"

View File

@ -23,7 +23,7 @@ class Mangachan(context: Context, override val id: Int) : ParsedOnlineSource(con
override fun popularMangaInitialUrl() = "$baseUrl/mostfavorites" override fun popularMangaInitialUrl() = "$baseUrl/mostfavorites"
override fun searchMangaInitialUrl(query: String) = "$baseUrl/?do=search&subaction=search&story=$query" override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/?do=search&subaction=search&story=$query"
override fun popularMangaSelector() = "div.content_row" override fun popularMangaSelector() = "div.content_row"

View File

@ -24,7 +24,7 @@ class Mintmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
override fun popularMangaInitialUrl() = "$baseUrl/list?sortType=rate" override fun popularMangaInitialUrl() = "$baseUrl/list?sortType=rate"
override fun searchMangaInitialUrl(query: String) = "$baseUrl/search?q=$query" override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/search?q=$query"
override fun popularMangaSelector() = "div.desc" override fun popularMangaSelector() = "div.desc"

View File

@ -24,7 +24,7 @@ class Readmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
override fun popularMangaInitialUrl() = "$baseUrl/list?sortType=rate" override fun popularMangaInitialUrl() = "$baseUrl/list?sortType=rate"
override fun searchMangaInitialUrl(query: String) = "$baseUrl/search?q=$query" override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/search?q=$query"
override fun popularMangaSelector() = "div.desc" override fun popularMangaSelector() = "div.desc"

View File

@ -2,13 +2,13 @@ package eu.kanade.tachiyomi.ui.catalogue
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.support.design.widget.Snackbar
import android.support.v7.widget.GridLayoutManager import android.support.v7.widget.GridLayoutManager
import android.support.v7.widget.LinearLayoutManager import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.SearchView import android.support.v7.widget.SearchView
import android.support.v7.widget.Toolbar import android.support.v7.widget.Toolbar
import android.view.* import android.view.*
import android.view.animation.AnimationUtils import android.view.animation.AnimationUtils
import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.Spinner import android.widget.Spinner
@ -25,6 +25,7 @@ import eu.kanade.tachiyomi.util.snack
import eu.kanade.tachiyomi.util.toast import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.widget.DividerItemDecoration import eu.kanade.tachiyomi.widget.DividerItemDecoration
import eu.kanade.tachiyomi.widget.EndlessScrollListener import eu.kanade.tachiyomi.widget.EndlessScrollListener
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
import kotlinx.android.synthetic.main.fragment_catalogue.* import kotlinx.android.synthetic.main.fragment_catalogue.*
import kotlinx.android.synthetic.main.toolbar.* import kotlinx.android.synthetic.main.toolbar.*
import nucleus.factory.RequiresPresenter import nucleus.factory.RequiresPresenter
@ -64,7 +65,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
/** /**
* Query of the search box. * Query of the search box.
*/ */
private val query: String? private val query: String
get() = presenter.query get() = presenter.query
/** /**
@ -92,11 +93,6 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
*/ */
private var numColumnsSubscription: Subscription? = null private var numColumnsSubscription: Subscription? = null
/**
* Display mode of the catalogue (list or grid mode).
*/
private var displayMode: MenuItem? = null
/** /**
* Search item. * Search item.
*/ */
@ -144,7 +140,8 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
catalogue_list.adapter = adapter catalogue_list.adapter = adapter
catalogue_list.layoutManager = llm catalogue_list.layoutManager = llm
catalogue_list.addOnScrollListener(listScrollListener) catalogue_list.addOnScrollListener(listScrollListener)
catalogue_list.addItemDecoration(DividerItemDecoration(context.theme.getResourceDrawable(R.attr.divider_drawable))) catalogue_list.addItemDecoration(
DividerItemDecoration(context.theme.getResourceDrawable(R.attr.divider_drawable)))
if (presenter.isListMode) { if (presenter.isListMode) {
switcher.showNext() switcher.showNext()
@ -166,8 +163,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
android.R.layout.simple_spinner_item, presenter.sources) android.R.layout.simple_spinner_item, presenter.sources)
spinnerAdapter.setDropDownViewResource(R.layout.spinner_item) spinnerAdapter.setDropDownViewResource(R.layout.spinner_item)
val onItemSelected = object : AdapterView.OnItemSelectedListener { val onItemSelected = IgnoreFirstSpinnerListener { position ->
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
val source = spinnerAdapter.getItem(position) val source = spinnerAdapter.getItem(position)
if (!presenter.isValidSource(source)) { if (!presenter.isValidSource(source)) {
spinner.setSelection(selectedIndex) spinner.setSelection(selectedIndex)
@ -178,16 +174,14 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
glm.scrollToPositionWithOffset(0, 0) glm.scrollToPositionWithOffset(0, 0)
llm.scrollToPositionWithOffset(0, 0) llm.scrollToPositionWithOffset(0, 0)
presenter.setActiveSource(source) presenter.setActiveSource(source)
activity.invalidateOptionsMenu()
} }
} }
override fun onNothingSelected(parent: AdapterView<*>) { selectedIndex = presenter.sources.indexOf(presenter.source)
}
}
spinner = Spinner(themedContext).apply { spinner = Spinner(themedContext).apply {
adapter = spinnerAdapter adapter = spinnerAdapter
selectedIndex = presenter.sources.indexOf(presenter.source)
setSelection(selectedIndex) setSelection(selectedIndex)
onItemSelectedListener = onItemSelected onItemSelectedListener = onItemSelected
} }
@ -205,7 +199,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
searchItem = menu.findItem(R.id.action_search).apply { searchItem = menu.findItem(R.id.action_search).apply {
val searchView = actionView as SearchView val searchView = actionView as SearchView
if (!query.isNullOrEmpty()) { if (!query.isBlank()) {
expandActionView() expandActionView()
searchView.setQuery(query, true) searchView.setQuery(query, true)
searchView.clearFocus() searchView.clearFocus()
@ -223,20 +217,31 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
}) })
} }
// Setup filters button
menu.findItem(R.id.action_set_filter).apply {
if (presenter.source.filters.isEmpty()) {
isEnabled = false
icon.alpha = 128
} else {
isEnabled = true
icon.alpha = 255
}
}
// Show next display mode // Show next display mode
displayMode = menu.findItem(R.id.action_display_mode).apply { menu.findItem(R.id.action_display_mode).apply {
val icon = if (presenter.isListMode) val icon = if (presenter.isListMode)
R.drawable.ic_view_module_white_24dp R.drawable.ic_view_module_white_24dp
else else
R.drawable.ic_view_list_white_24dp R.drawable.ic_view_list_white_24dp
setIcon(icon) setIcon(icon)
} }
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) { when (item.itemId) {
R.id.action_display_mode -> swapDisplayMode() R.id.action_display_mode -> swapDisplayMode()
R.id.action_set_filter -> showFiltersDialog()
else -> return super.onOptionsItemSelected(item) else -> return super.onOptionsItemSelected(item)
} }
return true return true
@ -312,7 +317,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
*/ */
fun onAddPage(page: Int, mangas: List<Manga>) { fun onAddPage(page: Int, mangas: List<Manga>) {
hideProgressBar() hideProgressBar()
if (page == 0) { if (page == 1) {
adapter.clear() adapter.clear()
gridScrollListener.resetScroll() gridScrollListener.resetScroll()
listScrollListener.resetScroll() listScrollListener.resetScroll()
@ -329,10 +334,10 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
hideProgressBar() hideProgressBar()
Timber.e(error, error.message) Timber.e(error, error.message)
catalogue_view.snack(error.message ?: "") { catalogue_view.snack(error.message ?: "", Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_retry) { setAction(R.string.action_retry) {
showProgressBar() showProgressBar()
presenter.retryPage() presenter.requestNext()
} }
} }
} }
@ -352,11 +357,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
fun swapDisplayMode() { fun swapDisplayMode() {
presenter.swapDisplayMode() presenter.swapDisplayMode()
val isListMode = presenter.isListMode val isListMode = presenter.isListMode
val icon = if (isListMode) activity.invalidateOptionsMenu()
R.drawable.ic_view_module_white_24dp
else
R.drawable.ic_view_list_white_24dp
displayMode?.setIcon(icon)
switcher.showNext() switcher.showNext()
if (!isListMode) { if (!isListMode) {
// Initialize mangas if going to grid view // Initialize mangas if going to grid view
@ -444,4 +445,27 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
}.show() }.show()
} }
/**
* Show the filter dialog for the source.
*/
private fun showFiltersDialog() {
val allFilters = presenter.source.filters
val selectedFilters = presenter.filters
.map { filter -> allFilters.indexOf(filter) }
.toTypedArray()
MaterialDialog.Builder(context)
.title(R.string.action_set_filter)
.items(allFilters.map { it.name })
.itemsCallbackMultiChoice(selectedFilters) { dialog, positions, text ->
val newFilters = positions.map { allFilters[it] }
showProgressBar()
presenter.setSourceFilter(newFilters)
true
}
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.show()
}
} }

View File

@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.ui.catalogue
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.data.source.online.OnlineSource.Filter
import rx.Observable
import rx.subjects.PublishSubject
class CataloguePager(val source: OnlineSource, val query: String, val filters: List<Filter>) {
private var lastPage: MangasPage? = null
private val results = PublishSubject.create<MangasPage>()
fun results(): Observable<MangasPage> {
return results.asObservable()
}
fun requestNext(transformer: (Observable<MangasPage>) -> Observable<MangasPage>): Observable<MangasPage> {
val lastPage = lastPage
val page = if (lastPage == null)
MangasPage(1)
else
MangasPage(lastPage.page + 1).apply { url = lastPage.nextPageUrl!! }
val observable = if (query.isBlank() && filters.isEmpty())
source.fetchPopularManga(page)
else
source.fetchSearchManga(page, query, filters)
return transformer(observable)
.doOnNext { results.onNext(it) }
.doOnNext { this@CataloguePager.lastPage = it }
}
fun hasNextPage(): Boolean {
return lastPage == null || lastPage?.nextPageUrl != null
}
}

View File

@ -12,9 +12,10 @@ import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.data.source.model.MangasPage import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.online.LoginSource import eu.kanade.tachiyomi.data.source.online.LoginSource
import eu.kanade.tachiyomi.data.source.online.OnlineSource import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.data.source.online.OnlineSource.Filter
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.RxPager
import rx.Observable import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import rx.subjects.PublishSubject import rx.subjects.PublishSubject
@ -64,14 +65,14 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
private set private set
/** /**
* Pager containing a list of manga results. * Active filters.
*/ */
private var pager = RxPager<Manga>() var filters: List<Filter> = emptyList()
/** /**
* Last fetched page from network. * Pager containing a list of manga results.
*/ */
private var lastMangasPage: MangasPage? = null private lateinit var pager: CataloguePager
/** /**
* Subject that initializes a list of manga. * Subject that initializes a list of manga.
@ -84,27 +85,20 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
var isListMode: Boolean = false var isListMode: Boolean = false
private set private set
companion object {
/** /**
* Id of the restartable that delivers a list of manga. * Subscription for the pager.
*/ */
const val PAGER = 1 private var pagerSubscription: Subscription? = null
/** /**
* Id of the restartable that requests a page of manga from network. * Subscription for one request from the pager.
*/ */
const val REQUEST_PAGE = 2 private var pageSubscription: Subscription? = null
/** /**
* Id of the restartable that initializes the details of manga. * Subscription to initialize manga details.
*/ */
const val GET_MANGA_DETAILS = 3 private var initializerSubscription: Subscription? = null
/**
* Key to save and restore [query] from a [Bundle].
*/
const val QUERY_KEY = "query_key"
}
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
@ -112,52 +106,68 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
source = getLastUsedSource() source = getLastUsedSource()
if (savedState != null) { if (savedState != null) {
query = savedState.getString(QUERY_KEY, "") query = savedState.getString(CataloguePresenter::query.name, "")
} }
startableLatestCache(GET_MANGA_DETAILS,
{ mangaDetailSubject.observeOn(Schedulers.io())
.flatMap { Observable.from(it) }
.filter { !it.initialized }
.concatMap { getMangaDetailsObservable(it) }
.onBackpressureBuffer()
.observeOn(AndroidSchedulers.mainThread()) },
{ view, manga -> view.onMangaInitialized(manga) },
{ view, error -> Timber.e(error.message) })
add(prefs.catalogueAsList().asObservable() add(prefs.catalogueAsList().asObservable()
.subscribe { setDisplayMode(it) }) .subscribe { setDisplayMode(it) })
startableReplay(PAGER, restartPager()
{ pager.results() },
{ view, pair -> view.onAddPage(pair.first, pair.second) })
startableFirst(REQUEST_PAGE,
{ pager.request { page -> getMangasPageObservable(page + 1) } },
{ view, next -> },
{ view, error -> view.onAddPageError(error) })
start(PAGER)
start(REQUEST_PAGE)
} }
override fun onSave(state: Bundle) { override fun onSave(state: Bundle) {
state.putString(QUERY_KEY, query) state.putString(CataloguePresenter::query.name, query)
super.onSave(state) super.onSave(state)
} }
/** /**
* Sets the display mode. * Restarts the pager for the active source with the provided query and filters.
* *
* @param asList whether the current mode is in list or not. * @param query the query.
* @param filters the list of active filters (for search mode).
*/ */
private fun setDisplayMode(asList: Boolean) { fun restartPager(query: String = this.query, filters: List<Filter> = this.filters) {
isListMode = asList this.query = query
if (asList) { this.filters = filters
stop(GET_MANGA_DETAILS)
} else { if (!isListMode) {
start(GET_MANGA_DETAILS) subscribeToMangaInitializer()
} }
// Create a new pager.
pager = CataloguePager(source, query, filters)
// Prepare the pager.
pagerSubscription?.let { remove(it) }
pagerSubscription = pager.results()
.subscribeReplay({ view, page ->
view.onAddPage(page.page, page.mangas)
}, { view, error ->
Timber.e(error, error.message)
})
// Request first page.
requestNext()
}
/**
* Requests the next page for the active pager.
*/
fun requestNext() {
if (!hasNextPage()) return
pageSubscription?.let { remove(it) }
pageSubscription = pager.requestNext { getPageTransformer(it) }
.subscribeFirst({ view, page ->
// Nothing to do when onNext is emitted.
}, CatalogueFragment::onAddPageError)
}
/**
* Returns true if the last fetched page has a next page.
*/
fun hasNextPage(): Boolean {
return pager.hasNextPage()
} }
/** /**
@ -168,73 +178,64 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
fun setActiveSource(source: OnlineSource) { fun setActiveSource(source: OnlineSource) {
prefs.lastUsedCatalogueSource().set(source.id) prefs.lastUsedCatalogueSource().set(source.id)
this.source = source this.source = source
restartPager()
restartPager(query = "", filters = emptyList())
} }
/** /**
* Restarts the request for the active source. * Sets the display mode.
* *
* @param query the query, or null if searching popular manga. * @param asList whether the current mode is in list or not.
*/ */
fun restartPager(query: String = "") { private fun setDisplayMode(asList: Boolean) {
this.query = query isListMode = asList
stop(REQUEST_PAGE) if (asList) {
lastMangasPage = null initializerSubscription?.let { remove(it) }
} else {
if (!isListMode) { subscribeToMangaInitializer()
start(GET_MANGA_DETAILS)
}
start(PAGER)
start(REQUEST_PAGE)
}
/**
* Requests the next page for the active pager.
*/
fun requestNext() {
if (hasNextPage()) {
start(REQUEST_PAGE)
} }
} }
/** /**
* Returns true if the last fetched page has a next page. * Subscribes to the initializer of manga details and updates the view if needed.
*/ */
fun hasNextPage(): Boolean { private fun subscribeToMangaInitializer() {
return lastMangasPage?.nextPageUrl != null initializerSubscription?.let { remove(it) }
} initializerSubscription = mangaDetailSubject.observeOn(Schedulers.io())
.flatMap { Observable.from(it) }
/** .filter { !it.initialized }
* Retries the current request that failed. .concatMap { getMangaDetailsObservable(it) }
*/ .onBackpressureBuffer()
fun retryPage() {
start(REQUEST_PAGE)
}
/**
* Returns the observable of the network request for a page.
*
* @param page the page number to request.
* @return an observable of the network request.
*/
private fun getMangasPageObservable(page: Int): Observable<List<Manga>> {
val nextMangasPage = MangasPage(page)
if (page != 1) {
nextMangasPage.url = lastMangasPage!!.nextPageUrl!!
}
val observable = if (query.isEmpty())
source.fetchPopularManga(nextMangasPage)
else
source.fetchSearchManga(nextMangasPage, query)
return observable.subscribeOn(Schedulers.io())
.doOnNext { lastMangasPage = it }
.flatMap { Observable.from(it.mangas) }
.map { networkToLocalManga(it) }
.toList()
.doOnNext { initializeMangas(it) }
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe({ manga ->
@Suppress("DEPRECATION")
view?.onMangaInitialized(manga)
}, { error ->
Timber.e(error, error.message)
})
.apply { add(this) }
}
/**
* Returns the function to apply to the observable of the list of manga from the source.
*
* @param observable the observable from the source.
* @return the function to apply.
*/
fun getPageTransformer(observable: Observable<MangasPage>): Observable<MangasPage> {
return observable.subscribeOn(Schedulers.io())
.doOnNext { it.mangas.replace { networkToLocalManga(it) } }
.doOnNext { initializeMangas(it.mangas) }
.observeOn(AndroidSchedulers.mainThread())
}
/**
* Replaces an object in the list with another.
*/
fun <T> MutableList<T>.replace(block: (T) -> T) {
forEachIndexed { i, obj ->
set(i, block(obj))
}
} }
/** /**
@ -354,4 +355,13 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
prefs.catalogueAsList().set(!isListMode) prefs.catalogueAsList().set(!isListMode)
} }
/**
* Set the active filters for the current source.
*
* @param selectedFilters a list of active filters.
*/
fun setSourceFilter(selectedFilters: List<Filter>) {
restartPager(filters = selectedFilters)
}
} }

View File

@ -1,21 +0,0 @@
package eu.kanade.tachiyomi.util
import android.util.Pair
import rx.Observable
import rx.subjects.PublishSubject
class RxPager<T> {
private val results = PublishSubject.create<List<T>>()
private var requestedCount: Int = 0
fun results(): Observable<Pair<Int, List<T>>> {
requestedCount = 0
return results.map { Pair(requestedCount++, it) }
}
fun request(networkObservable: (Int) -> Observable<List<T>>) =
networkObservable(requestedCount).doOnNext { results.onNext(it) }
}

View File

@ -1,6 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fitsSystemWindows="true" android:fitsSystemWindows="true"
@ -46,3 +51,5 @@
android:visibility="gone"/> android:visibility="gone"/>
</LinearLayout> </LinearLayout>
</android.support.design.widget.CoordinatorLayout>

View File

@ -9,6 +9,12 @@
app:showAsAction="collapseActionView|ifRoom" app:showAsAction="collapseActionView|ifRoom"
app:actionViewClass="android.support.v7.widget.SearchView"/> app:actionViewClass="android.support.v7.widget.SearchView"/>
<item
android:id="@+id/action_set_filter"
android:title="@string/action_set_filter"
android:icon="@drawable/ic_filter_list_white_24dp"
app:showAsAction="ifRoom"/>
<item <item
android:id="@+id/action_display_mode" android:id="@+id/action_display_mode"
android:title="@string/action_display_mode" android:title="@string/action_display_mode"

View File

@ -51,6 +51,7 @@
<string name="action_resume">Resume</string> <string name="action_resume">Resume</string>
<string name="action_open_in_browser">Open in browser</string> <string name="action_open_in_browser">Open in browser</string>
<string name="action_display_mode">Change display mode</string> <string name="action_display_mode">Change display mode</string>
<string name="action_set_filter">Set filter</string>
<string name="action_cancel">Cancel</string> <string name="action_cancel">Cancel</string>
<string name="action_sort">Sort</string> <string name="action_sort">Sort</string>
<string name="action_install">Install</string> <string name="action_install">Install</string>