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.
*/
fun fetchImage(page: Page): Observable<Page>
}

View File

@ -58,6 +58,11 @@ abstract class OnlineSource(context: Context) : Source {
*/
val headers by lazy { headersBuilder().build() }
/**
* Genre filters.
*/
val filters by lazy { getFilterList() }
/**
* Default network client for doing requests.
*/
@ -126,11 +131,11 @@ abstract class OnlineSource(context: Context) : Source {
* the current page and the next page url.
* @param query the search query.
*/
open fun fetchSearchManga(page: MangasPage, query: String): Observable<MangasPage> = client
.newCall(searchMangaRequest(page, query))
open fun fetchSearchManga(page: MangasPage, query: String, filters: List<Filter>): Observable<MangasPage> = client
.newCall(searchMangaRequest(page, query, filters))
.asObservable()
.map { response ->
searchMangaParse(response, page, query)
searchMangaParse(response, page, query, filters)
page
}
@ -141,9 +146,9 @@ abstract class OnlineSource(context: Context) : Source {
* @param page the page object.
* @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) {
page.url = searchMangaInitialUrl(query)
page.url = searchMangaInitialUrl(query, filters)
}
return GET(page.url, headers)
}
@ -153,7 +158,7 @@ abstract class OnlineSource(context: Context) : Source {
*
* @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
@ -163,7 +168,7 @@ abstract class OnlineSource(context: Context) : Source {
* @param page the page object to be filled.
* @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
@ -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 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()
for (element in document.select(searchMangaSelector())) {
Manga.create(id).apply {
@ -179,5 +179,4 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
* @param document the parsed document.
*/
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.network.GET
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.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
@ -14,6 +15,7 @@ import okhttp3.Request
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import rx.Observable
import java.text.SimpleDateFormat
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) {
page.url = searchMangaInitialUrl(query)
page.url = searchMangaInitialUrl(query, filters)
}
return when (map.search.method?.toLowerCase()) {
"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()
for (element in document.select(map.search.manga_css)) {
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")
}
}
}

View File

@ -84,9 +84,21 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
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()
for (element in document.select(searchMangaSelector())) {
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 {
"$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)
}
}
@ -211,7 +223,7 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
val start = pageUrl.indexOf("#") + 1
val end = pageUrl.indexOf("_", start)
val id = pageUrl.substring(start, end)
return GET("$baseUrl/areader?id=$id&p=${pageUrl.substring(end+1)}", pageHeaders)
return GET("$baseUrl/areader?id=$id&p=${pageUrl.substring(end + 1)}", pageHeaders)
}
override fun imageUrlParse(document: Document): String {
@ -219,10 +231,10 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
}
override fun login(username: String, password: String) =
client.newCall(GET("$baseUrl/forums/index.php?app=core&module=global&section=login", headers))
.asObservable()
.flatMap { doLogin(it, username, password) }
.map { isAuthenticationSuccessful(it) }
client.newCall(GET("$baseUrl/forums/index.php?app=core&module=global&section=login", headers))
.asObservable()
.flatMap { doLogin(it, username, password) }
.map { isAuthenticationSuccessful(it) }
private fun doLogin(response: Response, username: String, password: String): Observable<Response> {
val doc = response.asJsoup()
@ -242,7 +254,7 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
}
override fun isAuthenticationSuccessful(response: Response) =
response.priorResponse() != null && response.priorResponse().code() == 302
response.priorResponse() != null && response.priorResponse().code() == 302
override fun isLogged(): Boolean {
return network.cookies.get(URI(baseUrl)).any { it.name() == "pass_hash" }
@ -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 searchMangaRequest(page: MangasPage, query: String): Request {
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
if (page.page == 1) {
page.url = searchMangaInitialUrl(query)
page.url = searchMangaInitialUrl(query, filters)
}
val form = FormBody.Builder().apply {
add("authorArtist", "")
add("mangaName", query)
add("status", "")
add("genres", "")
}.build()
}
return POST(page.url, headers, form)
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) = "$baseUrl/AdvanceSearch"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/AdvanceSearch"
override fun searchMangaSelector() = popularMangaSelector()
@ -73,7 +85,7 @@ class Kissmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
manga.author = infoElement.select("p:has(span:contains(Author:)) > a").first()?.text()
manga.genre = infoElement.select("p:has(span:contains(Genres:)) > *:gt(0)").text()
manga.description = infoElement.select("p:has(span:contains(Summary:)) ~ p").text()
manga.status = infoElement.select("p:has(span:contains(Status:))").first()?.text().orEmpty().let { parseStatus(it)}
manga.status = infoElement.select("p:has(span:contains(Status:))").first()?.text().orEmpty().let { parseStatus(it) }
manga.thumbnail_url = document.select(".rightBox:eq(0) img").first()?.attr("src")
}
@ -109,10 +121,59 @@ class Kissmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
}
// 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 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 searchMangaInitialUrl(query: String) =
"$baseUrl/search.php?name_method=cw&advopts=1&order=za&sort=views&name=$query&page=1"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$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)"
@ -118,4 +118,43 @@ class Mangafox(context: Context, override val id: Int) : ParsedOnlineSource(cont
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 searchMangaInitialUrl(query: String) =
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/search.php?name=$query&page=1&sort=views&order=za"
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 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"
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.source.EN
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.Page
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import okhttp3.OkHttpClient
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 searchMangaInitialUrl(query: String) =
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$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) {
page.url = searchMangaInitialUrl(query)
page.url = searchMangaInitialUrl(query, filters)
}
var builder = okhttp3.FormBody.Builder()
val builder = okhttp3.FormBody.Builder()
builder.add("query", query)
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 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"

View File

@ -23,7 +23,7 @@ class Mangachan(context: Context, override val id: Int) : ParsedOnlineSource(con
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"

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 searchMangaInitialUrl(query: String) = "$baseUrl/search?q=$query"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/search?q=$query"
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 searchMangaInitialUrl(query: String) = "$baseUrl/search?q=$query"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/search?q=$query"
override fun popularMangaSelector() = "div.desc"

View File

@ -2,13 +2,13 @@ package eu.kanade.tachiyomi.ui.catalogue
import android.content.res.Configuration
import android.os.Bundle
import android.support.design.widget.Snackbar
import android.support.v7.widget.GridLayoutManager
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.SearchView
import android.support.v7.widget.Toolbar
import android.view.*
import android.view.animation.AnimationUtils
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.ProgressBar
import android.widget.Spinner
@ -25,6 +25,7 @@ import eu.kanade.tachiyomi.util.snack
import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.widget.DividerItemDecoration
import eu.kanade.tachiyomi.widget.EndlessScrollListener
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
import kotlinx.android.synthetic.main.fragment_catalogue.*
import kotlinx.android.synthetic.main.toolbar.*
import nucleus.factory.RequiresPresenter
@ -64,7 +65,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
/**
* Query of the search box.
*/
private val query: String?
private val query: String
get() = presenter.query
/**
@ -92,11 +93,6 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
*/
private var numColumnsSubscription: Subscription? = null
/**
* Display mode of the catalogue (list or grid mode).
*/
private var displayMode: MenuItem? = null
/**
* Search item.
*/
@ -144,7 +140,8 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
catalogue_list.adapter = adapter
catalogue_list.layoutManager = llm
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) {
switcher.showNext()
@ -166,28 +163,25 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
android.R.layout.simple_spinner_item, presenter.sources)
spinnerAdapter.setDropDownViewResource(R.layout.spinner_item)
val onItemSelected = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
val source = spinnerAdapter.getItem(position)
if (!presenter.isValidSource(source)) {
spinner.setSelection(selectedIndex)
context.toast(R.string.source_requires_login)
} else if (source != presenter.source) {
selectedIndex = position
showProgressBar()
glm.scrollToPositionWithOffset(0, 0)
llm.scrollToPositionWithOffset(0, 0)
presenter.setActiveSource(source)
}
}
override fun onNothingSelected(parent: AdapterView<*>) {
val onItemSelected = IgnoreFirstSpinnerListener { position ->
val source = spinnerAdapter.getItem(position)
if (!presenter.isValidSource(source)) {
spinner.setSelection(selectedIndex)
context.toast(R.string.source_requires_login)
} else if (source != presenter.source) {
selectedIndex = position
showProgressBar()
glm.scrollToPositionWithOffset(0, 0)
llm.scrollToPositionWithOffset(0, 0)
presenter.setActiveSource(source)
activity.invalidateOptionsMenu()
}
}
selectedIndex = presenter.sources.indexOf(presenter.source)
spinner = Spinner(themedContext).apply {
adapter = spinnerAdapter
selectedIndex = presenter.sources.indexOf(presenter.source)
setSelection(selectedIndex)
onItemSelectedListener = onItemSelected
}
@ -205,7 +199,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
searchItem = menu.findItem(R.id.action_search).apply {
val searchView = actionView as SearchView
if (!query.isNullOrEmpty()) {
if (!query.isBlank()) {
expandActionView()
searchView.setQuery(query, true)
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
displayMode = menu.findItem(R.id.action_display_mode).apply {
menu.findItem(R.id.action_display_mode).apply {
val icon = if (presenter.isListMode)
R.drawable.ic_view_module_white_24dp
else
R.drawable.ic_view_list_white_24dp
setIcon(icon)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_display_mode -> swapDisplayMode()
R.id.action_set_filter -> showFiltersDialog()
else -> return super.onOptionsItemSelected(item)
}
return true
@ -312,7 +317,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
*/
fun onAddPage(page: Int, mangas: List<Manga>) {
hideProgressBar()
if (page == 0) {
if (page == 1) {
adapter.clear()
gridScrollListener.resetScroll()
listScrollListener.resetScroll()
@ -329,10 +334,10 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
hideProgressBar()
Timber.e(error, error.message)
catalogue_view.snack(error.message ?: "") {
catalogue_view.snack(error.message ?: "", Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_retry) {
showProgressBar()
presenter.retryPage()
presenter.requestNext()
}
}
}
@ -352,11 +357,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
fun swapDisplayMode() {
presenter.swapDisplayMode()
val isListMode = presenter.isListMode
val icon = if (isListMode)
R.drawable.ic_view_module_white_24dp
else
R.drawable.ic_view_list_white_24dp
displayMode?.setIcon(icon)
activity.invalidateOptionsMenu()
switcher.showNext()
if (!isListMode) {
// Initialize mangas if going to grid view
@ -444,4 +445,27 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
}.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.online.LoginSource
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.util.RxPager
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subjects.PublishSubject
@ -64,14 +65,14 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
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.
@ -84,27 +85,20 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
var isListMode: Boolean = false
private set
companion object {
/**
* Id of the restartable that delivers a list of manga.
*/
const val PAGER = 1
/**
* Subscription for the pager.
*/
private var pagerSubscription: Subscription? = null
/**
* Id of the restartable that requests a page of manga from network.
*/
const val REQUEST_PAGE = 2
/**
* Subscription for one request from the pager.
*/
private var pageSubscription: Subscription? = null
/**
* Id of the restartable that initializes the details of manga.
*/
const val GET_MANGA_DETAILS = 3
/**
* Key to save and restore [query] from a [Bundle].
*/
const val QUERY_KEY = "query_key"
}
/**
* Subscription to initialize manga details.
*/
private var initializerSubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
@ -112,52 +106,68 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
source = getLastUsedSource()
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()
.subscribe { setDisplayMode(it) })
startableReplay(PAGER,
{ 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)
restartPager()
}
override fun onSave(state: Bundle) {
state.putString(QUERY_KEY, query)
state.putString(CataloguePresenter::query.name, query)
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) {
isListMode = asList
if (asList) {
stop(GET_MANGA_DETAILS)
} else {
start(GET_MANGA_DETAILS)
fun restartPager(query: String = this.query, filters: List<Filter> = this.filters) {
this.query = query
this.filters = filters
if (!isListMode) {
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) {
prefs.lastUsedCatalogueSource().set(source.id)
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 = "") {
this.query = query
stop(REQUEST_PAGE)
lastMangasPage = null
if (!isListMode) {
start(GET_MANGA_DETAILS)
}
start(PAGER)
start(REQUEST_PAGE)
}
/**
* Requests the next page for the active pager.
*/
fun requestNext() {
if (hasNextPage()) {
start(REQUEST_PAGE)
private fun setDisplayMode(asList: Boolean) {
isListMode = asList
if (asList) {
initializerSubscription?.let { remove(it) }
} else {
subscribeToMangaInitializer()
}
}
/**
* 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 {
return lastMangasPage?.nextPageUrl != null
}
/**
* Retries the current request that failed.
*/
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) }
private fun subscribeToMangaInitializer() {
initializerSubscription?.let { remove(it) }
initializerSubscription = mangaDetailSubject.observeOn(Schedulers.io())
.flatMap { Observable.from(it) }
.filter { !it.initialized }
.concatMap { getMangaDetailsObservable(it) }
.onBackpressureBuffer()
.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)
}
/**
* 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,48 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:orientation="vertical"
android:id="@+id/catalogue_view"
tools:context="eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment">
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ProgressBar
android:id="@+id/progress"
style="?android:attr/progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center_vertical|center_horizontal"
android:visibility="gone"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:orientation="vertical"
android:id="@+id/catalogue_view"
tools:context="eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment">
<ViewSwitcher
android:id="@+id/switcher"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<eu.kanade.tachiyomi.widget.AutofitRecyclerView
android:id="@+id/catalogue_grid"
style="@style/Theme.Widget.GridView"
android:layout_width="match_parent"
<ProgressBar
android:id="@+id/progress"
style="?android:attr/progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:columnWidth="140dp"
tools:listitem="@layout/item_catalogue_grid"/>
android:layout_gravity="center_vertical|center_horizontal"
android:visibility="gone"/>
<android.support.v7.widget.RecyclerView
android:id="@+id/catalogue_list"
<ViewSwitcher
android:id="@+id/switcher"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
android:layout_height="0dp"
android:layout_weight="1">
<eu.kanade.tachiyomi.widget.AutofitRecyclerView
android:id="@+id/catalogue_grid"
style="@style/Theme.Widget.GridView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:columnWidth="140dp"
tools:listitem="@layout/item_catalogue_grid"/>
</ViewSwitcher>
<android.support.v7.widget.RecyclerView
android:id="@+id/catalogue_list"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<ProgressBar
android:id="@+id/progress_grid"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|center_horizontal"
android:visibility="gone"/>
</ViewSwitcher>
</LinearLayout>
<ProgressBar
android:id="@+id/progress_grid"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|center_horizontal"
android:visibility="gone"/>
</LinearLayout>
</android.support.design.widget.CoordinatorLayout>

View File

@ -9,6 +9,12 @@
app:showAsAction="collapseActionView|ifRoom"
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
android:id="@+id/action_display_mode"
android:title="@string/action_display_mode"

View File

@ -51,6 +51,7 @@
<string name="action_resume">Resume</string>
<string name="action_open_in_browser">Open in browser</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_sort">Sort</string>
<string name="action_install">Install</string>