Add feed to the combined sources menus

This commit is contained in:
Jobobby04 2022-03-27 20:09:39 -04:00
parent 6a41d96ddf
commit d0e9d24f6f
20 changed files with 1187 additions and 51 deletions

View File

@ -77,13 +77,26 @@ fun getReadMangaNotInLibraryQuery() =
"""
/**
* Query to get the manga merged into a merged manga
* Query to get the global feed saved searches
*/
fun getFeedSavedSearchQuery() =
fun getGlobalFeedSavedSearchQuery() =
"""
SELECT ${SavedSearchTable.TABLE}.*
FROM (
SELECT ${FeedSavedSearchTable.COL_SAVED_SEARCH_ID} FROM ${FeedSavedSearchTable.TABLE}
SELECT ${FeedSavedSearchTable.COL_SAVED_SEARCH_ID} FROM ${FeedSavedSearchTable.TABLE} WHERE ${FeedSavedSearchTable.COL_GLOBAL} = 1
) AS M
JOIN ${SavedSearchTable.TABLE}
ON ${SavedSearchTable.TABLE}.${SavedSearchTable.COL_ID} = M.${FeedSavedSearchTable.COL_SAVED_SEARCH_ID}
"""
/**
* Query to get the source feed saved searches
*/
fun getSourceFeedSavedSearchQuery() =
"""
SELECT ${SavedSearchTable.TABLE}.*
FROM (
SELECT ${FeedSavedSearchTable.COL_SAVED_SEARCH_ID} FROM ${FeedSavedSearchTable.TABLE} WHERE ${FeedSavedSearchTable.COL_GLOBAL} = 0 AND ${FeedSavedSearchTable.COL_SOURCE} = ?
) AS M
JOIN ${SavedSearchTable.TABLE}
ON ${SavedSearchTable.TABLE}.${SavedSearchTable.COL_ID} = M.${FeedSavedSearchTable.COL_SAVED_SEARCH_ID}

View File

@ -167,12 +167,12 @@ open class FeedController :
* @param source used to find holder containing source
* @return the holder of the manga or null if it's not bound.
*/
private fun getHolder(source: CatalogueSource): FeedHolder? {
private fun getHolder(feed: FeedSavedSearch): FeedHolder? {
val adapter = adapter ?: return null
adapter.allBoundViewHolders.forEach { holder ->
val item = adapter.getItem(holder.bindingAdapterPosition)
if (item != null && source.id == item.feed.id) {
if (item != null && feed.id == item.feed.id) {
return holder as FeedHolder
}
}
@ -200,8 +200,8 @@ open class FeedController :
*
* @param manga the initialized manga.
*/
fun onMangaInitialized(source: CatalogueSource, manga: Manga) {
getHolder(source)?.setImage(manga)
fun onMangaInitialized(feed: FeedSavedSearch, manga: Manga) {
getHolder(feed)?.setImage(manga)
}
/**

View File

@ -7,7 +7,6 @@ import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItem
import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.models.SavedSearch
@ -62,8 +61,8 @@ class FeedItem(
* @return items are equal?
*/
override fun equals(other: Any?): Boolean {
if (other is GlobalSearchItem) {
return feed.id == other.source.id
if (other is FeedItem) {
return feed.id == other.feed.id
}
return false
}

View File

@ -52,7 +52,7 @@ open class FeedPresenter(
/**
* Subject which fetches image of given manga.
*/
private val fetchImageSubject = PublishSubject.create<Pair<List<Manga>, Source>>()
private val fetchImageSubject = PublishSubject.create<Triple<List<Manga>, Source, FeedSavedSearch>>()
/**
* Subscription for fetching images of manga.
@ -62,7 +62,7 @@ open class FeedPresenter(
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
db.getFeedSavedSearches()
db.getGlobalFeedSavedSearches()
.asRxObservable()
.observeOn(AndroidSchedulers.mainThread())
.doOnEach {
@ -79,7 +79,7 @@ open class FeedPresenter(
}
fun hasTooManyFeeds(): Boolean {
return db.getFeedSavedSearches().executeAsBlocking().size > 10
return db.getGlobalFeedSavedSearches().executeAsBlocking().size > 10
}
fun getEnabledSources(): List<CatalogueSource> {
@ -103,7 +103,8 @@ open class FeedPresenter(
FeedSavedSearch(
id = null,
source = source.id,
savedSearch = savedSearch?.id
savedSearch = savedSearch?.id,
global = true
)
).executeAsBlocking()
}
@ -116,9 +117,9 @@ open class FeedPresenter(
}
private fun getSourcesToGetFeed(): List<Pair<FeedSavedSearch, SavedSearch?>> {
val savedSearches = db.getSavedSearchesFeed().executeAsBlocking()
val savedSearches = db.getGlobalSavedSearchesFeed().executeAsBlocking()
.associateBy { it.id!! }
return db.getFeedSavedSearches().executeAsBlocking()
return db.getGlobalFeedSavedSearches().executeAsBlocking()
.map { it to savedSearches[it.savedSearch] }
}
@ -169,7 +170,7 @@ open class FeedPresenter(
.onErrorReturn { MangasPage(emptyList(), false) } // Ignore timeouts or other exceptions
.map { it.mangas } // Get manga from search result.
.map { list -> list.map { networkToLocalManga(it, source.id) } } // Convert to local manga.
.doOnNext { fetchImage(it, source) } // Load manga covers.
.doOnNext { fetchImage(it, source, feed) } // Load manga covers.
.map { list -> createCatalogueSearchItem(feed, savedSearch, source, list.map { FeedCardItem(it) }) }
} else {
Observable.just(createCatalogueSearchItem(feed, null, null, emptyList()))
@ -215,8 +216,8 @@ open class FeedPresenter(
*
* @param manga the list of manga to initialize.
*/
private fun fetchImage(manga: List<Manga>, source: Source) {
fetchImageSubject.onNext(Pair(manga, source))
private fun fetchImage(manga: List<Manga>, source: CatalogueSource, feed: FeedSavedSearch) {
fetchImageSubject.onNext(Triple(manga, source, feed))
}
/**
@ -230,14 +231,14 @@ open class FeedPresenter(
Observable.from(pair.first).filter { it.thumbnail_url == null && !it.initialized }
.map { Pair(it, source) }
.concatMap { getMangaDetailsObservable(it.first, it.second) }
.map { Pair(source as CatalogueSource, it) }
.map { Pair(pair.third, it) }
}
.onBackpressureBuffer()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ (source, manga) ->
{ (feed, manga) ->
@Suppress("DEPRECATION")
view?.onMangaInitialized(source, manga)
view?.onMangaInitialized(feed, manga)
},
{ error ->
logcat(LogPriority.ERROR, error)

View File

@ -29,8 +29,8 @@ import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.BrowseController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.browse.source.index.IndexController
import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
import eu.kanade.tachiyomi.ui.category.sources.ChangeSourceCategoriesDialog
import eu.kanade.tachiyomi.ui.main.MainActivity
@ -145,7 +145,7 @@ class SourceController(bundle: Bundle? = null) :
// Open the catalogue view.
// SY -->
if (source.supportsLatest && preferences.useNewSourceNavigation().get()) {
openIndexSource(source)
openSourceFeed(source)
} else openSource(source, BrowseSourceController(source))
// SY <--
}
@ -307,11 +307,11 @@ class SourceController(bundle: Bundle? = null) :
// SY -->
/**
* Opens a catalogue with the index controller.
* Opens a catalogue with the source feed controller.
*/
private fun openIndexSource(source: CatalogueSource) {
private fun openSourceFeed(source: CatalogueSource) {
preferences.lastUsedSource().set(source.id)
parentController!!.router.pushController(IndexController(source).withFadeTransaction())
parentController!!.router.pushController(SourceFeedController(source).withFadeTransaction())
}
// SY <--

View File

@ -0,0 +1,85 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import android.os.Bundle
import android.os.Parcelable
import android.util.SparseArray
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.models.SavedSearch
/**
* Adapter that holds the search cards.
*
* @param controller instance of [SourceFeedController].
*/
class SourceFeedAdapter(val controller: SourceFeedController) :
FlexibleAdapter<SourceFeedItem>(null, controller, true) {
val feedClickListener: OnFeedClickListener = controller
/**
* Bundle where the view state of the holders is saved.
*/
private var bundle = Bundle()
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List<Any?>) {
super.onBindViewHolder(holder, position, payloads)
restoreHolderState(holder)
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
super.onViewRecycled(holder)
saveHolderState(holder, bundle)
}
override fun onSaveInstanceState(outState: Bundle) {
val holdersBundle = Bundle()
allBoundViewHolders.forEach { saveHolderState(it, holdersBundle) }
outState.putBundle(HOLDER_BUNDLE_KEY, holdersBundle)
super.onSaveInstanceState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
bundle = savedInstanceState.getBundle(HOLDER_BUNDLE_KEY)!!
}
/**
* Saves the view state of the given holder.
*
* @param holder The holder to save.
* @param outState The bundle where the state is saved.
*/
private fun saveHolderState(holder: RecyclerView.ViewHolder, outState: Bundle) {
val key = "holder_${holder.bindingAdapterPosition}"
val holderState = SparseArray<Parcelable>()
holder.itemView.saveHierarchyState(holderState)
outState.putSparseParcelableArray(key, holderState)
}
/**
* Restores the view state of the given holder.
*
* @param holder The holder to restore.
*/
private fun restoreHolderState(holder: RecyclerView.ViewHolder) {
val key = "holder_${holder.bindingAdapterPosition}"
val holderState = bundle.getSparseParcelableArray<Parcelable>(key)
if (holderState != null) {
holder.itemView.restoreHierarchyState(holderState)
bundle.remove(key)
}
}
interface OnFeedClickListener {
fun onLatestClick()
fun onBrowseClick()
fun onSavedSearchClick(savedSearch: SavedSearch)
fun onRemoveClick(feedSavedSearch: FeedSavedSearch)
}
private companion object {
const val HOLDER_BUNDLE_KEY = "holder_bundle"
}
}

View File

@ -0,0 +1,27 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.database.models.Manga
/**
* Adapter that holds the manga items from search results.
*
* @param controller instance of [SourceFeedController].
*/
class SourceFeedCardAdapter(controller: SourceFeedController) :
FlexibleAdapter<SourceFeedCardItem>(null, controller, true) {
/**
* Listen for browse item clicks.
*/
val mangaClickListener: OnMangaClickListener = controller
/**
* Listener which should be called when user clicks browse.
* Note: Should only be handled by [SourceFeedController]
*/
interface OnMangaClickListener {
fun onMangaClick(manga: Manga)
fun onMangaLongClick(manga: Manga)
}
}

View File

@ -0,0 +1,58 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import android.view.View
import androidx.core.view.isVisible
import coil.dispose
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.databinding.GlobalSearchControllerCardItemBinding
import eu.kanade.tachiyomi.util.view.loadAutoPause
class SourceFeedCardHolder(view: View, adapter: SourceFeedCardAdapter) :
FlexibleViewHolder(view, adapter) {
private val binding = GlobalSearchControllerCardItemBinding.bind(view)
init {
// Call onMangaClickListener when item is pressed.
itemView.setOnClickListener {
val item = adapter.getItem(bindingAdapterPosition)
if (item != null) {
adapter.mangaClickListener.onMangaClick(item.manga)
}
}
itemView.setOnLongClickListener {
val item = adapter.getItem(bindingAdapterPosition)
if (item != null) {
adapter.mangaClickListener.onMangaLongClick(item.manga)
}
true
}
}
fun bind(manga: Manga) {
binding.card.clipToOutline = true
// Set manga title
binding.title.text = manga.title
// Set alpha of thumbnail.
binding.cover.alpha = if (manga.favorite) 0.3f else 1.0f
// For rounded corners
binding.badges.clipToOutline = true
// Set favorite badge
binding.favoriteText.isVisible = manga.favorite
setImage(manga)
}
fun setImage(manga: Manga) {
binding.cover.dispose()
binding.cover.loadAutoPause(manga) {
setParameter(MangaCoverFetcher.USE_CUSTOM_COVER, false)
}
}
}

View File

@ -0,0 +1,40 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
class SourceFeedCardItem(val manga: Manga) : AbstractFlexibleItem<SourceFeedCardHolder>() {
override fun getLayoutRes(): Int {
return R.layout.global_search_controller_card_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): SourceFeedCardHolder {
return SourceFeedCardHolder(view, adapter as SourceFeedCardAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: SourceFeedCardHolder,
position: Int,
payloads: List<Any?>?
) {
holder.bind(manga)
}
override fun equals(other: Any?): Boolean {
if (other is SourceFeedCardItem) {
return manga.id == other.manga.id
}
return false
}
override fun hashCode(): Int {
return manga.id?.toInt() ?: 0
}
}

View File

@ -0,0 +1,345 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.View
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.databinding.LatestControllerBinding
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.ui.base.controller.FabController
import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterSheet
import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.toast
import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.models.SavedSearch
import exh.util.nullIfBlank
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import xyz.nulldev.ts.api.http.serializer.FilterSerializer
/**
* This controller shows and manages the different search result in global search.
* This controller should only handle UI actions, IO actions should be done by [SourceFeedPresenter]
* [SourceFeedCardAdapter.OnMangaClickListener] called when manga is clicked in global search
*/
open class SourceFeedController :
SearchableNucleusController<LatestControllerBinding, SourceFeedPresenter>,
FabController,
SourceFeedCardAdapter.OnMangaClickListener,
SourceFeedAdapter.OnFeedClickListener {
constructor(source: CatalogueSource?) : super(
bundleOf(
SOURCE_EXTRA to (source?.id ?: 0)
)
) {
this.source = source
}
constructor(sourceId: Long) : this(
Injekt.get<SourceManager>().get(sourceId) as? CatalogueSource
)
@Suppress("unused")
constructor(bundle: Bundle) : this(bundle.getLong(SOURCE_EXTRA))
/**
* Adapter containing search results grouped by lang.
*/
protected var adapter: SourceFeedAdapter? = null
var source: CatalogueSource? = null
private var actionFab: ExtendedFloatingActionButton? = null
/**
* Sheet containing filter items.
*/
private var filterSheet: SourceFilterSheet? = null
init {
setHasOptionsMenu(true)
}
override fun getTitle(): String? {
return source!!.name
}
/**
* Create the [SourceFeedPresenter] used in controller.
*
* @return instance of [SourceFeedPresenter]
*/
override fun createPresenter(): SourceFeedPresenter {
return SourceFeedPresenter(source!!)
}
/**
* Called when manga in global search is clicked, opens manga.
*
* @param manga clicked item containing manga information.
*/
override fun onMangaClick(manga: Manga) {
// Open MangaController.
parentController?.router?.pushController(MangaController(manga, true).withFadeTransaction())
}
/**
* Called when manga in global search is long clicked.
*
* @param manga clicked item containing manga information.
*/
override fun onMangaLongClick(manga: Manga) {
// Delegate to single click by default.
onMangaClick(manga)
}
/**
* Adds items to the options menu.
*
* @param menu menu containing options.
* @param inflater used to load the menu xml.
*/
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
createOptionsMenu(menu, inflater, R.menu.global_search, R.id.action_search)
}
override fun onSearchViewQueryTextSubmit(query: String?) {
onBrowseClick(query.nullIfBlank())
}
override fun onSearchViewQueryTextChange(newText: String?) {
if (router.backstack.lastOrNull()?.controller == this) {
presenter.query = newText ?: ""
}
}
override fun createBinding(inflater: LayoutInflater): LatestControllerBinding = LatestControllerBinding.inflate(inflater)
/**
* Called when the view is created
*
* @param view view of controller
*/
override fun onViewCreated(view: View) {
super.onViewCreated(view)
// Prepare filter sheet
initFilterSheet()
binding.recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
adapter = SourceFeedAdapter(this)
// Create recycler and set adapter.
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.adapter = adapter
}
override fun onDestroyView(view: View) {
adapter = null
super.onDestroyView(view)
}
override fun onSaveViewState(view: View, outState: Bundle) {
super.onSaveViewState(view, outState)
adapter?.onSaveInstanceState(outState)
}
override fun onRestoreViewState(view: View, savedViewState: Bundle) {
super.onRestoreViewState(view, savedViewState)
adapter?.onRestoreInstanceState(savedViewState)
}
private val filterSerializer = FilterSerializer()
fun initFilterSheet() {
if (presenter.sourceFilters.isEmpty()) {
actionFab?.text = activity!!.getString(R.string.saved_searches)
}
filterSheet = SourceFilterSheet(
activity!!,
// SY -->
this,
presenter.source,
presenter.loadSearches(),
// SY <--
onFilterClicked = {
val allDefault = presenter.sourceFilters == presenter.source.getFilterList()
filterSheet?.dismiss()
if (allDefault) {
onBrowseClick(
presenter.query.nullIfBlank()
)
} else {
onBrowseClick(
presenter.query.nullIfBlank(),
filters = Json.encodeToString(filterSerializer.serialize(presenter.sourceFilters))
)
}
},
onResetClicked = {},
onSaveClicked = {},
onSavedSearchClicked = cb@{ idOfSearch ->
val search = presenter.loadSearch(idOfSearch)
if (search == null) {
filterSheet?.context?.let {
MaterialAlertDialogBuilder(it)
.setTitle(R.string.save_search_failed_to_load)
.setMessage(R.string.save_search_failed_to_load_message)
.show()
}
return@cb
}
if (search.filterList == null) {
activity?.toast(R.string.save_search_invalid)
return@cb
}
presenter.sourceFilters = FilterList(search.filterList)
filterSheet?.setFilters(presenter.filterItems)
val allDefault = presenter.sourceFilters == presenter.source.getFilterList()
filterSheet?.dismiss()
if (!allDefault) {
onBrowseClick(
search = presenter.query.nullIfBlank(),
savedSearch = search.id
)
}
},
onSavedSearchDeleteClicked = { idOfSearch, name ->
MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.feed)
.setMessage(activity!!.getString(R.string.feed_add, name))
.setPositiveButton(R.string.action_add) { _, _ ->
presenter.createFeed(idOfSearch)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
)
filterSheet?.setFilters(presenter.filterItems)
// TODO: [ExtendedFloatingActionButton] hide/show methods don't work properly
filterSheet?.setOnShowListener { actionFab?.isVisible = false }
filterSheet?.setOnDismissListener { actionFab?.isVisible = true }
actionFab?.setOnClickListener { filterSheet?.show() }
actionFab?.isVisible = true
}
override fun configureFab(fab: ExtendedFloatingActionButton) {
actionFab = fab
// Controlled by initFilterSheet()
fab.isVisible = false
fab.setText(R.string.action_filter)
fab.setIconResource(R.drawable.ic_filter_list_24dp)
}
override fun cleanupFab(fab: ExtendedFloatingActionButton) {
fab.setOnClickListener(null)
actionFab = null
}
/**
* Returns the view holder for the given manga.
*
* @param source used to find holder containing source
* @return the holder of the manga or null if it's not bound.
*/
private fun getHolder(sourceFeed: SourceFeed): SourceFeedHolder? {
val adapter = adapter ?: return null
adapter.allBoundViewHolders.forEach { holder ->
val item = adapter.getItem(holder.bindingAdapterPosition)
if (item != null && sourceFeed == item.sourceFeed) {
return holder as SourceFeedHolder
}
}
return null
}
/**
* Add search result to adapter.
*
* @param feedManga the source items containing the latest manga.
*/
fun setItems(feedManga: List<SourceFeedItem>) {
adapter?.updateDataSet(feedManga)
if (feedManga.isEmpty()) {
binding.emptyView.show(R.string.feed_tab_empty)
} else {
binding.emptyView.hide()
}
}
/**
* Called from the presenter when a manga is initialized.
*
* @param manga the initialized manga.
*/
fun onMangaInitialized(sourceFeed: SourceFeed, manga: Manga) {
getHolder(sourceFeed)?.setImage(manga)
}
fun onBrowseClick(search: String? = null, savedSearch: Long? = null, filters: String? = null) {
router.replaceTopController(BrowseSourceController(presenter.source, search, savedSearch = savedSearch, filterList = filters).withFadeTransaction())
}
override fun onLatestClick() {
router.replaceTopController(LatestUpdatesController(presenter.source).withFadeTransaction())
}
override fun onBrowseClick() {
router.replaceTopController(BrowseSourceController(presenter.source).withFadeTransaction())
}
override fun onSavedSearchClick(savedSearch: SavedSearch) {
router.replaceTopController(BrowseSourceController(presenter.source, savedSearch = savedSearch.id).withFadeTransaction())
}
override fun onRemoveClick(feedSavedSearch: FeedSavedSearch) {
MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.feed)
.setMessage(R.string.feed_delete)
.setPositiveButton(R.string.action_delete) { _, _ ->
presenter.deleteFeed(feedSavedSearch)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
companion object {
const val SOURCE_EXTRA = "source"
}
}

View File

@ -0,0 +1,125 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import android.annotation.SuppressLint
import android.view.View
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.databinding.LatestControllerCardBinding
/**
* Holder that binds the [SourceFeedItem] containing catalogue cards.
*
* @param view view of [SourceFeedItem]
* @param adapter instance of [SourceFeedAdapter]
*/
class SourceFeedHolder(view: View, val adapter: SourceFeedAdapter) :
FlexibleViewHolder(view, adapter) {
private val binding = LatestControllerCardBinding.bind(view)
/**
* Adapter containing manga from search results.
*/
private val mangaAdapter = SourceFeedCardAdapter(adapter.controller)
private var lastBoundResults: List<SourceFeedCardItem>? = null
init {
// Set layout horizontal.
binding.recycler.layoutManager = LinearLayoutManager(view.context, LinearLayoutManager.HORIZONTAL, false)
binding.recycler.adapter = mangaAdapter
binding.titleWrapper.setOnClickListener {
adapter.getItem(bindingAdapterPosition)?.let {
when (it.sourceFeed) {
SourceFeed.Browse -> adapter.feedClickListener.onBrowseClick()
SourceFeed.Latest -> adapter.feedClickListener.onLatestClick()
is SourceFeed.SourceSavedSearch -> adapter.feedClickListener.onSavedSearchClick(it.sourceFeed.savedSearch)
}
}
}
binding.titleWrapper.setOnLongClickListener {
adapter.getItem(bindingAdapterPosition)?.let {
when (it.sourceFeed) {
SourceFeed.Browse -> adapter.feedClickListener.onBrowseClick()
SourceFeed.Latest -> adapter.feedClickListener.onLatestClick()
is SourceFeed.SourceSavedSearch -> adapter.feedClickListener.onRemoveClick(it.sourceFeed.feed)
}
}
true
}
}
/**
* Show the loading of source search result.
*
* @param item item of card.
*/
@SuppressLint("SetTextI18n")
fun bind(item: SourceFeedItem) {
val results = item.results
when (item.sourceFeed) {
SourceFeed.Browse -> binding.title.setText(R.string.browse)
SourceFeed.Latest -> binding.title.setText(R.string.latest)
is SourceFeed.SourceSavedSearch -> binding.title.text = item.sourceFeed.savedSearch.name
}
when {
results == null -> {
binding.progress.isVisible = true
showResultsHolder()
}
results.isEmpty() -> {
binding.progress.isVisible = false
showNoResults()
}
else -> {
binding.progress.isVisible = false
showResultsHolder()
}
}
if (results !== lastBoundResults) {
mangaAdapter.updateDataSet(results)
lastBoundResults = results
}
}
/**
* Called from the presenter when a manga is initialized.
*
* @param manga the initialized manga.
*/
fun setImage(manga: Manga) {
getHolder(manga)?.setImage(manga)
}
/**
* Returns the view holder for the given manga.
*
* @param manga the manga to find.
* @return the holder of the manga or null if it's not bound.
*/
private fun getHolder(manga: Manga): SourceFeedCardHolder? {
mangaAdapter.allBoundViewHolders.forEach { holder ->
val item = mangaAdapter.getItem(holder.bindingAdapterPosition)
if (item != null && item.manga.id!! == manga.id!!) {
return holder as SourceFeedCardHolder
}
}
return null
}
private fun showResultsHolder() {
binding.noResultsFound.isVisible = false
}
private fun showNoResults() {
binding.noResultsFound.isVisible = true
}
}

View File

@ -0,0 +1,73 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
/**
* Item that contains search result information.
*
* @param feed the source for the search results.
* @param results the search results.
* @param highlighted whether this search item should be highlighted/marked in the catalogue search view.
*/
class SourceFeedItem(
val sourceFeed: SourceFeed,
val results: List<SourceFeedCardItem>?,
val highlighted: Boolean = false
) : AbstractFlexibleItem<SourceFeedHolder>() {
/**
* Set view.
*
* @return id of view
*/
override fun getLayoutRes(): Int {
return R.layout.global_search_controller_card
}
/**
* Create view holder (see [SourceFeedAdapter].
*
* @return holder of view.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): SourceFeedHolder {
return SourceFeedHolder(view, adapter as SourceFeedAdapter)
}
/**
* Bind item to view.
*/
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: SourceFeedHolder,
position: Int,
payloads: List<Any?>?
) {
holder.bind(this)
}
/**
* Used to check if two items are equal.
*
* @return items are equal?
*/
override fun equals(other: Any?): Boolean {
if (other is SourceFeedItem) {
return sourceFeed == other.sourceFeed
}
return false
}
/**
* Return hash code of item.
*
* @return hashcode
*/
override fun hashCode(): Int {
return sourceFeed.hashCode()
}
}

View File

@ -0,0 +1,357 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import android.os.Bundle
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.toMangaInfo
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.toSManga
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter.Companion.toItems
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.runAsObservable
import eu.kanade.tachiyomi.util.system.logcat
import exh.log.xLogE
import exh.savedsearches.EXHSavedSearch
import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.models.SavedSearch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import logcat.LogPriority
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subjects.PublishSubject
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import xyz.nulldev.ts.api.http.serializer.FilterSerializer
import java.lang.RuntimeException
sealed class SourceFeed {
object Latest : SourceFeed()
object Browse : SourceFeed()
data class SourceSavedSearch(val feed: FeedSavedSearch, val savedSearch: SavedSearch) : SourceFeed()
}
/**
* Presenter of [SourceFeedController]
* Function calls should be done from here. UI calls should be done from the controller.
*
* @param source the source.
* @param db manages the database calls.
* @param preferences manages the preference calls.
*/
open class SourceFeedPresenter(
val source: CatalogueSource,
val db: DatabaseHelper = Injekt.get(),
val preferences: PreferencesHelper = Injekt.get()
) : BasePresenter<SourceFeedController>() {
/**
* Fetches the different sources by user settings.
*/
private var fetchSourcesSubscription: Subscription? = null
/**
* Subject which fetches image of given manga.
*/
private val fetchImageSubject = PublishSubject.create<Triple<List<Manga>, Source, SourceFeed>>()
/**
* Subscription for fetching images of manga.
*/
private var fetchImageSubscription: Subscription? = null
/**
* Modifiable list of filters.
*/
var sourceFilters = FilterList()
set(value) {
field = value
filterItems = value.toItems()
}
var filterItems: List<IFlexible<*>> = emptyList()
init {
query = ""
}
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
sourceFilters = source.getFilterList()
db.getSourceFeedSavedSearches(source.id)
.asRxObservable()
.observeOn(AndroidSchedulers.mainThread())
.doOnEach {
getFeed()
}
.subscribe()
.let(::add)
}
override fun onDestroy() {
fetchSourcesSubscription?.unsubscribe()
fetchImageSubscription?.unsubscribe()
super.onDestroy()
}
fun hasTooManyFeeds(): Boolean {
return db.getSourceFeedSavedSearches(source.id).executeAsBlocking().size > 10
}
fun getSourceSavedSearches(): List<SavedSearch> {
return db.getSavedSearches(source.id).executeAsBlocking()
}
fun createFeed(savedSearchId: Long) {
launchIO {
db.insertFeedSavedSearch(
FeedSavedSearch(
id = null,
source = source.id,
savedSearch = savedSearchId,
global = false
)
).executeAsBlocking()
}
}
fun deleteFeed(feed: FeedSavedSearch) {
launchIO {
db.deleteFeedSavedSearch(feed).executeAsBlocking()
}
}
private fun getSourcesToGetFeed(): List<SourceFeed> {
val savedSearches = db.getSourceSavedSearchesFeed(source.id).executeAsBlocking()
.associateBy { it.id!! }
return listOf(SourceFeed.Latest, SourceFeed.Browse) + db.getSourceFeedSavedSearches(source.id).executeAsBlocking()
.map { SourceFeed.SourceSavedSearch(it, savedSearches[it.savedSearch]!!) }
}
/**
* Creates a catalogue search item
*/
protected open fun createCatalogueSearchItem(
sourceFeed: SourceFeed,
results: List<SourceFeedCardItem>?
): SourceFeedItem {
return SourceFeedItem(sourceFeed, results)
}
/**
* Initiates get manga per feed.
*/
fun getFeed() {
// Create image fetch subscription
initializeFetchImageSubscription()
// Create items with the initial state
val initialItems = getSourcesToGetFeed().map {
createCatalogueSearchItem(
it,
null
)
}
var items = initialItems
fetchSourcesSubscription?.unsubscribe()
fetchSourcesSubscription = Observable.from(getSourcesToGetFeed())
.flatMap(
{ sourceFeed ->
Observable.defer {
when (sourceFeed) {
SourceFeed.Browse -> source.fetchPopularManga(1)
SourceFeed.Latest -> source.fetchLatestUpdates(1)
is SourceFeed.SourceSavedSearch -> source.fetchSearchManga(
page = 1,
query = sourceFeed.savedSearch.query.orEmpty(),
filters = getFilterList(sourceFeed.savedSearch, source)
)
}
}
.subscribeOn(Schedulers.io())
.onErrorReturn { MangasPage(emptyList(), false) } // Ignore timeouts or other exceptions
.map { it.mangas } // Get manga from search result.
.map { list -> list.map { networkToLocalManga(it, source.id) } } // Convert to local manga.
.doOnNext { fetchImage(it, source, sourceFeed) } // Load manga covers.
.map { list -> createCatalogueSearchItem(sourceFeed, list.map { SourceFeedCardItem(it) }) }
},
5
)
.observeOn(AndroidSchedulers.mainThread())
// Update matching source with the obtained results
.map { result ->
items.map { item -> if (item.sourceFeed == result.sourceFeed) result else item }
}
// Update current state
.doOnNext { items = it }
// Deliver initial state
.startWith(initialItems)
.subscribeLatestCache(
{ view, manga ->
view.setItems(manga)
},
{ _, error ->
logcat(LogPriority.ERROR, error)
}
)
}
private val filterSerializer = FilterSerializer()
private fun getFilterList(savedSearch: SavedSearch, source: CatalogueSource): FilterList {
val filters = savedSearch.filtersJson ?: return FilterList()
return runCatching {
val originalFilters = source.getFilterList()
filterSerializer.deserialize(
filters = originalFilters,
json = Json.decodeFromString(filters)
)
originalFilters
}.getOrElse { FilterList() }
}
/**
* Initialize a list of manga.
*
* @param manga the list of manga to initialize.
*/
private fun fetchImage(manga: List<Manga>, source: Source, sourceFeed: SourceFeed) {
fetchImageSubject.onNext(Triple(manga, source, sourceFeed))
}
/**
* Subscribes to the initializer of manga details and updates the view if needed.
*/
private fun initializeFetchImageSubscription() {
fetchImageSubscription?.unsubscribe()
fetchImageSubscription = fetchImageSubject.observeOn(Schedulers.io())
.flatMap { pair ->
val source = pair.second
Observable.from(pair.first).filter { it.thumbnail_url == null && !it.initialized }
.map { Pair(it, source) }
.concatMap { getMangaDetailsObservable(it.first, it.second) }
.map { Pair(pair.third, it) }
}
.onBackpressureBuffer()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ (sourceFeed, manga) ->
@Suppress("DEPRECATION")
view?.onMangaInitialized(sourceFeed, manga)
},
{ error ->
logcat(LogPriority.ERROR, error)
}
)
}
/**
* Returns an observable of manga that initializes the given manga.
*
* @param manga the manga to initialize.
* @return an observable of the manga to initialize
*/
private fun getMangaDetailsObservable(manga: Manga, source: Source): Observable<Manga> {
return runAsObservable {
val networkManga = source.getMangaDetails(manga.toMangaInfo())
manga.copyFrom(networkManga.toSManga())
manga.initialized = true
db.insertManga(manga).executeAsBlocking()
manga
}
.onErrorResumeNext { Observable.just(manga) }
}
/**
* 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.
*
* @param sManga the manga from the source.
* @return a manga from the database.
*/
protected open fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga {
var localManga = db.getManga(sManga.url, sourceId).executeAsBlocking()
if (localManga == null) {
val newManga = Manga.create(sManga.url, sManga.title, sourceId)
newManga.copyFrom(sManga)
val result = db.insertManga(newManga).executeAsBlocking()
newManga.id = result.insertedId()
localManga = newManga
}
return localManga
}
fun loadSearch(searchId: Long): EXHSavedSearch? {
val search = db.getSavedSearch(searchId).executeAsBlocking() ?: return null
return EXHSavedSearch(
id = search.id!!,
name = search.name,
query = search.query.orEmpty(),
filterList = runCatching {
val originalFilters = source.getFilterList()
filterSerializer.deserialize(
filters = originalFilters,
json = search.filtersJson
?.let { Json.decodeFromString<JsonArray>(it) }
?: return@runCatching null
)
originalFilters
}.getOrNull()
)
}
fun loadSearches(): List<EXHSavedSearch> {
return db.getSavedSearches(source.id).executeAsBlocking().map {
val filtersJson = it.filtersJson ?: return@map EXHSavedSearch(
id = it.id!!,
name = it.name,
query = it.query.orEmpty(),
filterList = null
)
val filters = try {
Json.decodeFromString<JsonArray>(filtersJson)
} catch (e: Exception) {
null
} ?: return@map EXHSavedSearch(
id = it.id!!,
name = it.name,
query = it.query.orEmpty(),
filterList = null
)
try {
val originalFilters = source.getFilterList()
filterSerializer.deserialize(originalFilters, filters)
EXHSavedSearch(
id = it.id!!,
name = it.name,
query = it.query.orEmpty(),
filterList = originalFilters
)
} catch (t: RuntimeException) {
// Load failed
xLogE("Failed to load saved search!", t)
EXHSavedSearch(
id = it.id!!,
name = it.name,
query = it.query.orEmpty(),
filterList = null
)
}
}
}
}

View File

@ -66,8 +66,8 @@ import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationContr
import eu.kanade.tachiyomi.ui.browse.source.SourceController
import eu.kanade.tachiyomi.ui.browse.source.SourceController.Companion.SMART_SEARCH_SOURCE_TAG
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.browse.source.index.IndexController
import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.library.ChangeMangaCoverDialog
@ -921,7 +921,7 @@ class MangaController :
previousController.searchWithQuery(query)
}
// SY -->
is IndexController -> {
is SourceFeedController -> {
router.handleBack()
previousController.onBrowseClick(query)
}

View File

@ -423,7 +423,8 @@ object EXHMigrations {
FeedSavedSearch(
id = null,
source = it.toLong(),
savedSearch = null
savedSearch = null,
global = true
)
}?.ifEmpty { null }
if (feed != null) {

View File

@ -11,6 +11,7 @@ import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.tables.FeedSavedSearchTable.COL_GLOBAL
import exh.savedsearches.tables.FeedSavedSearchTable.COL_ID
import exh.savedsearches.tables.FeedSavedSearchTable.COL_SAVED_SEARCH_ID
import exh.savedsearches.tables.FeedSavedSearchTable.COL_SOURCE
@ -37,7 +38,8 @@ class FeedSavedSearchPutResolver : DefaultPutResolver<FeedSavedSearch>() {
override fun mapToContentValues(obj: FeedSavedSearch) = contentValuesOf(
COL_ID to obj.id,
COL_SOURCE to obj.source,
COL_SAVED_SEARCH_ID to obj.savedSearch
COL_SAVED_SEARCH_ID to obj.savedSearch,
COL_GLOBAL to obj.global
)
}
@ -46,7 +48,8 @@ class FeedSavedSearchGetResolver : DefaultGetResolver<FeedSavedSearch>() {
override fun mapFromCursor(cursor: Cursor): FeedSavedSearch = FeedSavedSearch(
id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_ID)),
source = cursor.getLong(cursor.getColumnIndexOrThrow(COL_SOURCE)),
savedSearch = cursor.getLongOrNull(cursor.getColumnIndexOrThrow(COL_SAVED_SEARCH_ID))
savedSearch = cursor.getLongOrNull(cursor.getColumnIndexOrThrow(COL_SAVED_SEARCH_ID)),
global = cursor.getInt(cursor.getColumnIndexOrThrow(COL_GLOBAL)) == 1
)
}

View File

@ -8,5 +8,8 @@ data class FeedSavedSearch(
var source: Long,
// If -1 then get latest, if set get the saved search
var savedSearch: Long?
var savedSearch: Long?,
// If the feed is a global or source specific feed
var global: Boolean
)

View File

@ -4,40 +4,32 @@ import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.Query
import com.pushtorefresh.storio.sqlite.queries.RawQuery
import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.queries.getFeedSavedSearchQuery
import eu.kanade.tachiyomi.data.database.queries.getGlobalFeedSavedSearchQuery
import eu.kanade.tachiyomi.data.database.queries.getSourceFeedSavedSearchQuery
import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.models.SavedSearch
import exh.savedsearches.tables.FeedSavedSearchTable
interface FeedSavedSearchQueries : DbProvider {
fun getFeedSavedSearches() = db.get()
fun getGlobalFeedSavedSearches() = db.get()
.listOfObjects(FeedSavedSearch::class.java)
.withQuery(
Query.builder()
.table(FeedSavedSearchTable.TABLE)
.where("${FeedSavedSearchTable.COL_GLOBAL} = 1")
.orderBy(FeedSavedSearchTable.COL_ID)
.build()
)
.prepare()
fun getFeedSavedSearch(id: Long) = db.get()
.`object`(FeedSavedSearch::class.java)
.withQuery(
Query.builder()
.table(FeedSavedSearchTable.TABLE)
.where("${FeedSavedSearchTable.COL_ID} = ?")
.whereArgs(id)
.build()
)
.prepare()
fun getFeedSavedSearches(ids: List<Long>) = db.get()
fun getSourceFeedSavedSearches(sourceId: Long) = db.get()
.listOfObjects(FeedSavedSearch::class.java)
.withQuery(
Query.builder()
.table(FeedSavedSearchTable.TABLE)
.where("${FeedSavedSearchTable.COL_ID} IN (?)")
.whereArgs(ids.joinToString())
.where("${FeedSavedSearchTable.COL_SOURCE} = ? AND ${FeedSavedSearchTable.COL_GLOBAL} = 0")
.whereArgs(sourceId)
.orderBy(FeedSavedSearchTable.COL_ID)
.build()
)
.prepare()
@ -64,11 +56,21 @@ interface FeedSavedSearchQueries : DbProvider {
)
.prepare()
fun getSavedSearchesFeed() = db.get()
fun getGlobalSavedSearchesFeed() = db.get()
.listOfObjects(SavedSearch::class.java)
.withQuery(
RawQuery.builder()
.query(getFeedSavedSearchQuery())
.query(getGlobalFeedSavedSearchQuery())
.build()
)
.prepare()
fun getSourceSavedSearchesFeed(sourceId: Long) = db.get()
.listOfObjects(SavedSearch::class.java)
.withQuery(
RawQuery.builder()
.query(getSourceFeedSavedSearchQuery())
.args(sourceId)
.build()
)
.prepare()

View File

@ -10,12 +10,15 @@ object FeedSavedSearchTable {
const val COL_SAVED_SEARCH_ID = "saved_search"
const val COL_GLOBAL = "global"
val createTableQuery: String
get() =
"""CREATE TABLE $TABLE(
$COL_ID INTEGER NOT NULL PRIMARY KEY,
$COL_SOURCE INTEGER NOT NULL,
$COL_SAVED_SEARCH_ID INTEGER,
$COL_GLOBAL BOOLEAN NOT NULL,
FOREIGN KEY($COL_SAVED_SEARCH_ID) REFERENCES ${SavedSearchTable.TABLE} (${SavedSearchTable.COL_ID})
ON DELETE CASCADE
)"""

View File

@ -366,6 +366,7 @@
<string name="feed_delete">Delete feed item?</string>
<string name="too_many_in_feed">Too many sources in your feed, cannot add more then 10</string>
<string name="feed_tab_empty">You don\'t have any sources in your feed, go to the sources tab and long press a source to watch it</string>
<string name="feed_add">Add %1$s to feed?</string>
<!-- Sort by tags -->
<string name="pref_tag_sorting">Tag sorting tags</string>