diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index 5dd124399..798044e8c 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -256,4 +256,8 @@ object PreferenceKeys { const val eh_ehentai_quality = "ehentai_quality" const val eh_enable_hah = "eh_enable_hah" + + const val latest_tab_sources = "latest_tab_sources" + + const val latest_tab_position = "latest_tab_position" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index eee41129d..a7f9c537b 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -356,4 +356,8 @@ class PreferencesHelper(val context: Context) { fun eh_settingsLanguages() = flowPrefs.getString(Keys.eh_settings_languages, "false*false*false\nfalse*false*false\nfalse*false*false\nfalse*false*false\nfalse*false*false\nfalse*false*false\nfalse*false*false\nfalse*false*false\nfalse*false*false\nfalse*false*false\nfalse*false*false\nfalse*false*false\nfalse*false*false\nfalse*false*false\nfalse*false*false\nfalse*false*false\nfalse*false*false") fun eh_EnabledCategories() = flowPrefs.getString(Keys.eh_enabled_categories, "false,false,false,false,false,false,false,false,false,false") + + fun latestTabSources() = flowPrefs.getStringSet(Keys.latest_tab_sources, mutableSetOf()) + + fun latestTabInFront() = flowPrefs.getBoolean(Keys.latest_tab_position, false) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt index c1d60cd31..991628c53 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt @@ -20,6 +20,7 @@ import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.RxController import eu.kanade.tachiyomi.ui.base.controller.TabbedController import eu.kanade.tachiyomi.ui.browse.extension.ExtensionController +import eu.kanade.tachiyomi.ui.browse.latest.LatestController import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrationSourcesController import eu.kanade.tachiyomi.ui.browse.source.SourceController import kotlinx.android.synthetic.main.main_activity.tabs @@ -100,7 +101,7 @@ class BrowseController : activity?.tabs?.apply { val updates = preferences.extensionUpdatesCount().get() if (updates > 0) { - val badge: BadgeDrawable? = getTabAt(1)?.orCreateBadge + val badge: BadgeDrawable? = getTabAt(EXTENSIONS_CONTROLLER)?.orCreateBadge badge?.isVisible = true } else { getTabAt(EXTENSIONS_CONTROLLER)?.removeBadge() @@ -110,11 +111,24 @@ class BrowseController : private inner class BrowseAdapter : RouterPagerAdapter(this@BrowseController) { - private val tabTitles = listOf( - R.string.label_sources, - R.string.label_extensions, - R.string.label_migration - ) + private val tabTitles = ( + if (preferences.latestTabInFront().get()) { + listOf( + R.string.latest, + R.string.label_sources, + R.string.label_extensions, + R.string.label_migration + + ) + } else { + listOf( + R.string.label_sources, + R.string.latest, + R.string.label_extensions, + R.string.label_migration + ) + } + ) .map { resources!!.getString(it) } override fun getCount(): Int { @@ -124,7 +138,8 @@ class BrowseController : override fun configureRouter(router: Router, position: Int) { if (!router.hasRootController()) { val controller: Controller = when (position) { - SOURCES_CONTROLLER -> SourceController() + SOURCES_CONTROLLER -> if (preferences.latestTabInFront().get()) LatestController() else SourceController() + LATEST_CONTROLLER -> if (!preferences.latestTabInFront().get()) LatestController() else SourceController() EXTENSIONS_CONTROLLER -> ExtensionController() MIGRATION_CONTROLLER -> MigrationSourcesController() else -> error("Wrong position $position") @@ -142,7 +157,8 @@ class BrowseController : const val TO_EXTENSIONS_EXTRA = "to_extensions" const val SOURCES_CONTROLLER = 0 - const val EXTENSIONS_CONTROLLER = 1 - const val MIGRATION_CONTROLLER = 2 + const val LATEST_CONTROLLER = 1 + const val EXTENSIONS_CONTROLLER = 2 + const val MIGRATION_CONTROLLER = 3 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestAdapter.kt new file mode 100644 index 000000000..36650e718 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestAdapter.kt @@ -0,0 +1,83 @@ +package eu.kanade.tachiyomi.ui.browse.source.globalsearch + +import android.os.Bundle +import android.os.Parcelable +import android.util.SparseArray +import androidx.recyclerview.widget.RecyclerView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.ui.browse.latest.LatestController +import eu.kanade.tachiyomi.ui.browse.latest.LatestItem + +/** + * Adapter that holds the search cards. + * + * @param controller instance of [LatestController]. + */ +class LatestAdapter(val controller: LatestController) : + FlexibleAdapter(null, controller, true) { + + val titleClickListener: OnTitleClickListener = 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) { + 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() + 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(key) + if (holderState != null) { + holder.itemView.restoreHierarchyState(holderState) + bundle.remove(key) + } + } + + interface OnTitleClickListener { + fun onTitleClick(source: CatalogueSource) + } + + private companion object { + const val HOLDER_BUNDLE_KEY = "holder_bundle" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestCardAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestCardAdapter.kt new file mode 100644 index 000000000..6be1069e8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestCardAdapter.kt @@ -0,0 +1,27 @@ +package eu.kanade.tachiyomi.ui.browse.latest + +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 [LatestController]. + */ +class LatestCardAdapter(controller: LatestController) : + FlexibleAdapter(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 [LatestController] + */ + interface OnMangaClickListener { + fun onMangaClick(manga: Manga) + fun onMangaLongClick(manga: Manga) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestCardHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestCardHolder.kt new file mode 100644 index 000000000..360714a62 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestCardHolder.kt @@ -0,0 +1,54 @@ +package eu.kanade.tachiyomi.ui.browse.latest + +import android.view.View +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.glide.toMangaThumbnail +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import eu.kanade.tachiyomi.widget.StateImageViewTarget +import kotlinx.android.synthetic.main.global_search_controller_comfortable_card_item.itemImage +import kotlinx.android.synthetic.main.global_search_controller_comfortable_card_item.progress +import kotlinx.android.synthetic.main.global_search_controller_comfortable_card_item.tvTitle + +class LatestCardHolder(view: View, adapter: LatestCardAdapter) : + BaseFlexibleViewHolder(view, adapter) { + + 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) { + tvTitle.text = manga.title + // Set alpha of thumbnail. + itemImage.alpha = if (manga.favorite) 0.3f else 1.0f + + setImage(manga) + } + + fun setImage(manga: Manga) { + GlideApp.with(itemView.context).clear(itemImage) + if (!manga.thumbnail_url.isNullOrEmpty()) { + GlideApp.with(itemView.context) + .load(manga.toMangaThumbnail()) + .diskCacheStrategy(DiskCacheStrategy.DATA) + .centerCrop() + .skipMemoryCache(true) + .placeholder(android.R.color.transparent) + .into(StateImageViewTarget(itemImage, progress)) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestCardItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestCardItem.kt new file mode 100644 index 000000000..58721ce4d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestCardItem.kt @@ -0,0 +1,47 @@ +package eu.kanade.tachiyomi.ui.browse.latest + +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 +import eu.kanade.tachiyomi.data.preference.PreferenceValues +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class LatestCardItem(val manga: Manga) : AbstractFlexibleItem() { + + override fun getLayoutRes(): Int { + return when (Injekt.get().catalogueDisplayMode().get()) { + PreferenceValues.DisplayMode.COMPACT_GRID -> R.layout.global_search_controller_compact_card_item + else -> R.layout.global_search_controller_comfortable_card_item + } + } + + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): LatestCardHolder { + return LatestCardHolder(view, adapter as LatestCardAdapter) + } + + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: LatestCardHolder, + position: Int, + payloads: List? + ) { + holder.bind(manga) + } + + override fun equals(other: Any?): Boolean { + if (other is LatestCardItem) { + return manga.id == other.manga.id + } + return false + } + + override fun hashCode(): Int { + return manga.id?.toInt() ?: 0 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestController.kt new file mode 100644 index 000000000..87e92f2ee --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestController.kt @@ -0,0 +1,204 @@ +package eu.kanade.tachiyomi.ui.browse.latest + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.preference.asImmediateFlow +import eu.kanade.tachiyomi.databinding.LatestControllerBinding +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.LatestAdapter +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.LatestPresenter +import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController +import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController +import eu.kanade.tachiyomi.ui.manga.MangaController +import kotlinx.coroutines.flow.launchIn + +/** + * 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 [LatestPresenter] + * [LatestCardAdapter.OnMangaClickListener] called when manga is clicked in global search + */ +open class LatestController : + NucleusController(), + LatestCardAdapter.OnMangaClickListener, + LatestAdapter.OnTitleClickListener { + + /** + * Adapter containing search results grouped by lang. + */ + protected var adapter: LatestAdapter? = null + + /*init { + setHasOptionsMenu(true) + }*/ + + /** + * Initiate the view with [R.layout.global_search_controller]. + * + * @param inflater used to load the layout xml. + * @param container containing parent views. + * @return inflated view + */ + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + binding = LatestControllerBinding.inflate(inflater) + return binding.root + } + + override fun getTitle(): String? { + return applicationContext?.getString(R.string.latest) + } + + /** + * Create the [LatestPresenter] used in controller. + * + * @return instance of [LatestPresenter] + */ + override fun createPresenter(): LatestPresenter { + return LatestPresenter() + } + + /** + * Called when manga in global search is clicked, opens manga. + * + * @param manga clicked item containing manga information. + */ + override fun onMangaClick(manga: Manga) { + // Open MangaController. + if (presenter.preferences.eh_useNewMangaInterface().get()) { + parentController?.router?.pushController(MangaAllInOneController(manga, true).withFadeTransaction()) + } else { + 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) { + // Inflate menu. + /*inflater.inflate(R.menu.global_search, menu) + + // Initialize search menu + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + searchView.maxWidth = Int.MAX_VALUE + + searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem?): Boolean { + searchView.onActionViewExpanded() // Required to show the query in the view + searchView.setQuery(presenter.query, false) + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem?): Boolean { + return true + } + })*/ + } + + /** + * Called when the view is created + * + * @param view view of controller + */ + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + adapter = LatestAdapter(this) + + // Create recycler and set adapter. + binding.recycler.layoutManager = LinearLayoutManager(view.context) + binding.recycler.adapter = adapter + + presenter.preferences.latestTabSources() + .asImmediateFlow { presenter.getLatest() } + .launchIn(scope) + } + + 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) + } + + /** + * 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(source: CatalogueSource): LatestHolder? { + val adapter = adapter ?: return null + + adapter.allBoundViewHolders.forEach { holder -> + val item = adapter.getItem(holder.bindingAdapterPosition) + if (item != null && source.id == item.source.id) { + return holder as LatestHolder + } + } + + return null + } + + /** + * Add search result to adapter. + * + * @param latestManga the source items containing the latest manga. + */ + fun setItems(latestManga: List) { + adapter?.updateDataSet(latestManga) + + if (latestManga.isEmpty()) { + binding.emptyView.show(R.string.latest_tab_empty) + } else { + binding.emptyView.hide() + } + } + + /** + * Called from the presenter when a manga is initialized. + * + * @param manga the initialized manga. + */ + fun onMangaInitialized(source: CatalogueSource, manga: Manga) { + getHolder(source)?.setImage(manga) + } + + /** + * Opens a catalogue with the given search. + */ + override fun onTitleClick(source: CatalogueSource) { + presenter.preferences.lastUsedCatalogueSource().set(source.id) + parentController?.router?.pushController(LatestUpdatesController(source).withFadeTransaction()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestHolder.kt new file mode 100644 index 000000000..21ada79cf --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestHolder.kt @@ -0,0 +1,114 @@ +package eu.kanade.tachiyomi.ui.browse.latest + +import android.view.View +import androidx.recyclerview.widget.LinearLayoutManager +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.LatestAdapter +import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.visible +import kotlinx.android.synthetic.main.latest_controller_card.progress +import kotlinx.android.synthetic.main.latest_controller_card.recycler +import kotlinx.android.synthetic.main.latest_controller_card.source_card +import kotlinx.android.synthetic.main.latest_controller_card.title +import kotlinx.android.synthetic.main.latest_controller_card.title_wrapper + +/** + * Holder that binds the [LatestItem] containing catalogue cards. + * + * @param view view of [LatestItem] + * @param adapter instance of [LatestAdapter] + */ +class LatestHolder(view: View, val adapter: LatestAdapter) : + BaseFlexibleViewHolder(view, adapter) { + + /** + * Adapter containing manga from search results. + */ + private val mangaAdapter = LatestCardAdapter(adapter.controller) + + private var lastBoundResults: List? = null + + init { + // Set layout horizontal. + recycler.layoutManager = LinearLayoutManager(view.context, LinearLayoutManager.HORIZONTAL, false) + recycler.adapter = mangaAdapter + + title_wrapper.setOnClickListener { + adapter.getItem(bindingAdapterPosition)?.let { + adapter.titleClickListener.onTitleClick(it.source) + } + } + } + + /** + * Show the loading of source search result. + * + * @param item item of card. + */ + fun bind(item: LatestItem) { + val source = item.source + val results = item.results + + val titlePrefix = if (item.highlighted) "▶ " else "" + val langSuffix = if (source.lang.isNotEmpty()) " (${source.lang})" else "" + + // Set Title with country code if available. + title.text = titlePrefix + source.name + langSuffix + + when { + results == null -> { + progress.visible() + showHolder() + } + results.isEmpty() -> { + progress.gone() + hideHolder() + } + else -> { + progress.gone() + showHolder() + } + } + 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): LatestCardHolder? { + mangaAdapter.allBoundViewHolders.forEach { holder -> + val item = mangaAdapter.getItem(holder.bindingAdapterPosition) + if (item != null && item.manga.id!! == manga.id!!) { + return holder as LatestCardHolder + } + } + + return null + } + + private fun showHolder() { + title_wrapper.visible() + source_card.visible() + } + + private fun hideHolder() { + title_wrapper.gone() + source_card.gone() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestItem.kt new file mode 100644 index 000000000..ba0d1f684 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestItem.kt @@ -0,0 +1,73 @@ +package eu.kanade.tachiyomi.ui.browse.latest + +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.source.CatalogueSource +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItem +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.LatestAdapter + +/** + * Item that contains search result information. + * + * @param source 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 LatestItem(val source: CatalogueSource, val results: List?, val highlighted: Boolean = false) : + AbstractFlexibleItem() { + + /** + * Set view. + * + * @return id of view + */ + override fun getLayoutRes(): Int { + return R.layout.global_search_controller_card + } + + /** + * Create view holder (see [LatestAdapter]. + * + * @return holder of view. + */ + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): LatestHolder { + return LatestHolder(view, adapter as LatestAdapter) + } + + /** + * Bind item to view. + */ + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: LatestHolder, + position: Int, + payloads: List? + ) { + holder.bind(this) + } + + /** + * Used to check if two items are equal. + * + * @return items are equal? + */ + override fun equals(other: Any?): Boolean { + if (other is GlobalSearchItem) { + return source.id == other.source.id + } + return false + } + + /** + * Return hash code of item. + * + * @return hashcode + */ + override fun hashCode(): Int { + return source.id.toInt() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestPresenter.kt new file mode 100644 index 000000000..f0e409ef9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/latest/LatestPresenter.kt @@ -0,0 +1,202 @@ +package eu.kanade.tachiyomi.ui.browse.source.globalsearch + +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.ui.browse.latest.LatestCardItem +import eu.kanade.tachiyomi.ui.browse.latest.LatestController +import eu.kanade.tachiyomi.ui.browse.latest.LatestItem +import rx.Observable +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import rx.subjects.PublishSubject +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * Presenter of [LatestController] + * Function calls should be done from here. UI calls should be done from the controller. + * + * @param sourceManager manages the different sources. + * @param db manages the database calls. + * @param preferences manages the preference calls. + */ +open class LatestPresenter( + private val sourcesToUse: List? = null, + val sourceManager: SourceManager = Injekt.get(), + val db: DatabaseHelper = Injekt.get(), + val preferences: PreferencesHelper = Injekt.get() +) : BasePresenter() { + + /** + * Fetches the different sources by user settings. + */ + private var fetchSourcesSubscription: Subscription? = null + + /** + * Subject which fetches image of given manga. + */ + private val fetchImageSubject = PublishSubject.create, Source>>() + + /** + * Subscription for fetching images of manga. + */ + private var fetchImageSubscription: Subscription? = null + + override fun onDestroy() { + fetchSourcesSubscription?.unsubscribe() + fetchImageSubscription?.unsubscribe() + super.onDestroy() + } + + /** + * Returns a list of enabled sources ordered by language and name, with pinned catalogues + * prioritized. + * + * @return list containing enabled sources. + */ + protected open fun getEnabledSources(): List { + val languages = preferences.enabledLanguages().get() + val watchedSources = preferences.latestTabSources().get() + + val list = sourceManager.getVisibleCatalogueSources() + .filter { it.lang in languages } + .sortedBy { "(${it.lang}) ${it.name}" } + + return list.filter { it.id.toString() in watchedSources } + } + + private fun getSourcesToGetLatest(): List { + return getEnabledSources() + } + + /** + * Creates a catalogue search item + */ + protected open fun createCatalogueSearchItem(source: CatalogueSource, results: List?): LatestItem { + return LatestItem(source, results) + } + + /** + * Initiates get latest per watching source. + */ + fun getLatest() { + // Create image fetch subscription + initializeFetchImageSubscription() + + // Create items with the initial state + val initialItems = getSourcesToGetLatest().map { createCatalogueSearchItem(it, null) } + var items = initialItems + + fetchSourcesSubscription?.unsubscribe() + fetchSourcesSubscription = Observable.from(getSourcesToGetLatest()) + .flatMap( + { source -> + Observable.defer { source.fetchLatestUpdates(1) } + .subscribeOn(Schedulers.io()) + .onErrorReturn { MangasPage(emptyList(), false) } // Ignore timeouts or other exceptions + .map { it.mangas.take(10) } // Get at most 10 manga from search result. + .map { list -> list.map { networkToLocalManga(it, source.id) } } // Convert to local manga. + .doOnNext { fetchImage(it, source) } // Load manga covers. + .map { list -> createCatalogueSearchItem(source, list.map { LatestCardItem(it) }) } + }, + 5 + ) + .observeOn(AndroidSchedulers.mainThread()) + // Update matching source with the obtained results + .map { result -> + items.map { item -> if (item.source == result.source) result else item } + } + // Update current state + .doOnNext { items = it } + // Deliver initial state + .startWith(initialItems) + .subscribeLatestCache( + { view, manga -> + view.setItems(manga) + }, + { _, error -> + Timber.e(error) + } + ) + } + + /** + * Initialize a list of manga. + * + * @param manga the list of manga to initialize. + */ + private fun fetchImage(manga: List, source: Source) { + fetchImageSubject.onNext(Pair(manga, source)) + } + + /** + * 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(source as CatalogueSource, it) } + } + .onBackpressureBuffer() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { (source, manga) -> + @Suppress("DEPRECATION") + view?.onMangaInitialized(source, manga) + }, + { error -> + Timber.e(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 { + return source.fetchMangaDetails(manga) + .flatMap { networkManga -> + manga.copyFrom(networkManga) + manga.initialized = true + db.insertManga(manga).executeAsBlocking() + Observable.just(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 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt index d36d88ebc..6436e210c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt @@ -30,6 +30,7 @@ import eu.kanade.tachiyomi.ui.browse.BrowseController import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController +import eu.kanade.tachiyomi.util.system.toast import exh.ui.smartsearch.SmartSearchController import kotlinx.android.parcel.Parcelize import kotlinx.coroutines.flow.filterIsInstance @@ -167,6 +168,17 @@ class SourceController(bundle: Bundle? = null) : items.add(Pair(activity.getString(R.string.action_hide), { hideCatalogue(item.source) })) } + val isWatched = preferences.latestTabSources().get().contains(item.source.id.toString()) + + if (item.source.supportsLatest) { + items.add( + Pair( + activity.getString(if (isWatched) R.string.unwatch else R.string.watch), + { watchCatalogue(item.source, isWatched) } + ) + ) + } + MaterialDialog(activity) .title(text = item.source.name) .listItems( @@ -197,6 +209,19 @@ class SourceController(bundle: Bundle? = null) : presenter.updateSources() } + private fun watchCatalogue(source: Source, isWatched: Boolean) { + val current = preferences.latestTabSources().get() + + if (isWatched) { + preferences.latestTabSources().set(current - source.id.toString()) + } else { + if (current.size + 1 !in 0..5) { + applicationContext?.toast(R.string.too_many_watched) + return + } + preferences.latestTabSources().set(current + source.id.toString()) + } + } /** * Called when browse is clicked in [SourceAdapter] */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt index ab4d0bd7b..19e7911e4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt @@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.extension.ExtensionUpdateJob import eu.kanade.tachiyomi.util.preference.defaultValue import eu.kanade.tachiyomi.util.preference.onChange import eu.kanade.tachiyomi.util.preference.preferenceCategory +import eu.kanade.tachiyomi.util.preference.summaryRes import eu.kanade.tachiyomi.util.preference.switchPreference import eu.kanade.tachiyomi.util.preference.titleRes @@ -15,6 +16,17 @@ class SettingsBrowseController : SettingsController() { override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { titleRes = R.string.browse + preferenceCategory { + titleRes = R.string.pref_category_general + + switchPreference { + key = Keys.latest_tab_position + titleRes = R.string.pref_latest_position + summaryRes = R.string.pref_latest_position_summery + defaultValue = false + } + } + preferenceCategory { titleRes = R.string.label_extensions diff --git a/app/src/main/res/layout/latest_controller.xml b/app/src/main/res/layout/latest_controller.xml new file mode 100644 index 000000000..ac54f51a5 --- /dev/null +++ b/app/src/main/res/layout/latest_controller.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/latest_controller_card.xml b/app/src/main/res/layout/latest_controller_card.xml new file mode 100644 index 000000000..8e9432c07 --- /dev/null +++ b/app/src/main/res/layout/latest_controller_card.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings_extra.xml b/app/src/main/res/values/strings_extra.xml index df0b423b5..68db9d24a 100644 --- a/app/src/main/res/values/strings_extra.xml +++ b/app/src/main/res/values/strings_extra.xml @@ -117,6 +117,12 @@ Auto Webtoon Mode Detection Reading webtoon style Loading gallery… + Watch + Unwatch + Latest tab position + Do you want the latest tab to be the first tab in browse? This will make it the default tab when opening browse, not recommended if your on data or a metered network + Too many watched sources, cannot add more then 5 + You don\'t have any watched sources, go to the sources tab and long press a source to watch it See Recommendations