Browse Source

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
Robin Appelman 10 năm trước cách đây
mục cha
commit
2fb3b50535
21 tập tin đã thay đổi với 480 bổ sung247 xóa
  1. 0 1
      app/src/main/java/eu/kanade/tachiyomi/data/source/Source.kt
  2. 15 7
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/OnlineSource.kt
  3. 1 2
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/ParsedOnlineSource.kt
  4. 6 5
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/YamlOnlineSource.kt
  5. 65 9
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Batoto.kt
  6. 69 8
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Kissmanga.kt
  7. 41 2
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangafox.kt
  8. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangahere.kt
  9. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangasee.kt
  10. 6 4
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Readmangatoday.kt
  11. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/german/WieManga.kt
  12. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/russian/Mangachan.kt
  13. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/russian/Mintmanga.kt
  14. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/russian/Readmanga.kt
  15. 60 36
      app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueFragment.kt
  16. 41 0
      app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePager.kt
  17. 115 105
      app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt
  18. 0 21
      app/src/main/java/eu/kanade/tachiyomi/util/RxPager.kt
  19. 48 41
      app/src/main/res/layout/fragment_catalogue.xml
  20. 6 0
      app/src/main/res/menu/catalogue_list.xml
  21. 1 0
      app/src/main/res/values/strings.xml

+ 0 - 1
app/src/main/java/eu/kanade/tachiyomi/data/source/Source.kt

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

+ 15 - 7
app/src/main/java/eu/kanade/tachiyomi/data/source/online/OnlineSource.kt

@@ -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()
 }

+ 1 - 2
app/src/main/java/eu/kanade/tachiyomi/data/source/online/ParsedOnlineSource.kt

@@ -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
-
 }

+ 6 - 5
app/src/main/java/eu/kanade/tachiyomi/data/source/online/YamlOnlineSource.kt

@@ -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")
         }
     }
-
 }

+ 65 - 9
app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Batoto.kt

@@ -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")
+    )
 }

+ 69 - 8
app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Kissmanga.kt

@@ -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()
+        }
+
+        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)
+        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")
+    )
 }

+ 41 - 2
app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangafox.kt

@@ -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")
+    )
 }

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangahere.kt

@@ -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)"

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangasee.kt

@@ -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"

+ 6 - 4
app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Readmangatoday.kt

@@ -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())

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/source/online/german/WieManga.kt

@@ -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"
 

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/source/online/russian/Mangachan.kt

@@ -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"
 

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/source/online/russian/Mintmanga.kt

@@ -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"
 

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/source/online/russian/Readmanga.kt

@@ -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"
 

+ 60 - 36
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueFragment.kt

@@ -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()
+    }
+
 }

+ 41 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePager.kt

@@ -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 { [email protected] = it }
+    }
+
+    fun hasNextPage(): Boolean {
+        return lastPage == null || lastPage?.nextPageUrl != null
+    }
+
+}

+ 115 - 105
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt

@@ -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
-
-        /**
-         * Id of the restartable that requests a page of manga from network.
-         */
-        const val REQUEST_PAGE = 2
-
-        /**
-         * 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 for the pager.
+     */
+    private var pagerSubscription: Subscription? = null
+
+    /**
+     * Subscription for one request from the pager.
+     */
+    private var pageSubscription: Subscription? = null
+
+    /**
+     * Subscription to initialize manga details.
+     */
+    private var initializerSubscription: Subscription? = null
 
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
@@ -112,131 +106,138 @@ 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.
-     *
-     * @param asList whether the current mode is in list or not.
-     */
-    private fun setDisplayMode(asList: Boolean) {
-        isListMode = asList
-        if (asList) {
-            stop(GET_MANGA_DETAILS)
-        } else {
-            start(GET_MANGA_DETAILS)
-        }
-    }
-
-    /**
-     * Sets the active source and restarts the pager.
-     *
-     * @param source the new active source.
-     */
-    fun setActiveSource(source: OnlineSource) {
-        prefs.lastUsedCatalogueSource().set(source.id)
-        this.source = source
-        restartPager()
-    }
-
-    /**
-     * Restarts the request for the active source.
+     * Restarts the pager for the active source with the provided query and filters.
      *
-     * @param query the query, or null if searching popular manga.
+     * @param query the query.
+     * @param filters the list of active filters (for search mode).
      */
-    fun restartPager(query: String = "") {
+    fun restartPager(query: String = this.query, filters: List<Filter> = this.filters) {
         this.query = query
-        stop(REQUEST_PAGE)
-        lastMangasPage = null
+        this.filters = filters
 
         if (!isListMode) {
-            start(GET_MANGA_DETAILS)
+            subscribeToMangaInitializer()
         }
-        start(PAGER)
-        start(REQUEST_PAGE)
+
+        // 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()) {
-            start(REQUEST_PAGE)
-        }
+        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 lastMangasPage?.nextPageUrl != null
+        return pager.hasNextPage()
     }
 
     /**
-     * Retries the current request that failed.
+     * Sets the active source and restarts the pager.
+     *
+     * @param source the new active source.
      */
-    fun retryPage() {
-        start(REQUEST_PAGE)
+    fun setActiveSource(source: OnlineSource) {
+        prefs.lastUsedCatalogueSource().set(source.id)
+        this.source = source
+
+        restartPager(query = "", filters = emptyList())
     }
 
     /**
-     * Returns the observable of the network request for a page.
+     * Sets the display mode.
      *
-     * @param page the page number to request.
-     * @return an observable of the network request.
+     * @param asList whether the current mode is in list or not.
      */
-    private fun getMangasPageObservable(page: Int): Observable<List<Manga>> {
-        val nextMangasPage = MangasPage(page)
-        if (page != 1) {
-            nextMangasPage.url = lastMangasPage!!.nextPageUrl!!
+    private fun setDisplayMode(asList: Boolean) {
+        isListMode = asList
+        if (asList) {
+            initializerSubscription?.let { remove(it) }
+        } else {
+            subscribeToMangaInitializer()
         }
+    }
 
-        val observable = if (query.isEmpty())
-            source.fetchPopularManga(nextMangasPage)
-        else
-            source.fetchSearchManga(nextMangasPage, query)
+    /**
+     * Subscribes to the initializer of manga details and updates the view if needed.
+     */
+    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 { lastMangasPage = it }
-                .flatMap { Observable.from(it.mangas) }
-                .map { networkToLocalManga(it) }
-                .toList()
-                .doOnNext { initializeMangas(it) }
+                .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))
+        }
+    }
+
     /**
      * Returns a manga from the database for the given manga from network. It creates a new entry
      * if the manga is not yet in the database.
@@ -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)
+    }
+
 }

+ 0 - 21
app/src/main/java/eu/kanade/tachiyomi/util/RxPager.kt

@@ -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) }
-
-}
-

+ 48 - 41
app/src/main/res/layout/fragment_catalogue.xml

@@ -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">
-
-    <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"/>
-
-
-    <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"
+<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">
+
+    <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">
+
+        <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"/>
+
+            <android.support.v7.widget.RecyclerView
+                android:id="@+id/catalogue_list"
+                android:layout_width="match_parent"
+                android:layout_height="match_parent"/>
+
+        </ViewSwitcher>
 
-    </ViewSwitcher>
+        <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"/>
 
-    <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>
 
-</LinearLayout>
+</android.support.design.widget.CoordinatorLayout>

+ 6 - 0
app/src/main/res/menu/catalogue_list.xml

@@ -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"

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -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>