Explorar o código

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 %!s(int64=8) %!d(string=hai) anos
pai
achega
2fb3b50535
Modificáronse 21 ficheiros con 480 adicións e 247 borrados
  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>