From 1c98f9181d7115d5d62e3766a4b6caeacf1428af Mon Sep 17 00:00:00 2001 From: jhmiramon <–43830312+jhmiramon@users.noreply.github.com> Date: Thu, 22 Apr 2021 13:29:46 +0200 Subject: [PATCH] let's hope this compiles! --- .../data/animelib/AnimelibUpdateNotifier.kt | 3 +- .../database/queries/AnimeTrackQueries.kt | 22 +- .../tachiyomi/data/glide/AnimeThumbnail.kt | 15 + .../data/notification/NotificationReceiver.kt | 17 +- .../data/notification/Notifications.kt | 3 + .../data/preference/PreferenceKeys.kt | 2 + .../data/preference/PreferencesHelper.kt | 4 + .../tachiyomi/data/track/TrackService.kt | 9 + .../tachiyomi/source/AnimeCatalogueSource.kt | 46 + .../eu/kanade/tachiyomi/source/AnimeSource.kt | 88 ++ .../tachiyomi/source/CatalogueSource.kt | 26 +- .../tachiyomi/source/LocalAnimeSource.kt | 324 +++++ .../eu/kanade/tachiyomi/source/LocalSource.kt | 122 +- .../java/eu/kanade/tachiyomi/source/Source.kt | 32 +- .../tachiyomi/ui/anime/AnimeController.kt | 1089 +++++++++++++++++ .../tachiyomi/ui/anime/AnimePresenter.kt | 797 ++++++++++++ .../episode/AnimeEpisodesHeaderAdapter.kt | 70 ++ .../ui/anime/episode/DeleteEpisodesDialog.kt | 29 + .../episode/DownloadCustomEpisodesDialog.kt | 77 ++ .../ui/anime/episode/EpisodeDownloadView.kt | 70 ++ .../ui/anime/episode/EpisodeHolder.kt | 82 ++ .../tachiyomi/ui/anime/episode/EpisodeItem.kt | 33 + .../ui/anime/episode/EpisodesAdapter.kt | 45 + .../ui/anime/episode/EpisodesSettingsSheet.kt | 269 ++++ .../anime/episode/SetEpisodeSettingsDialog.kt | 47 + .../anime/episode/base/BaseEpisodeHolder.kt | 38 + .../ui/anime/episode/base/BaseEpisodeItem.kt | 47 + .../anime/episode/base/BaseEpisodesAdapter.kt | 22 + .../ui/anime/info/AnimeCoverImageView.kt | 24 + .../ui/anime/info/AnimeInfoHeaderAdapter.kt | 358 ++++++ .../ui/anime/track/SetTrackEpisodesDialog.kt | 79 ++ .../ui/anime/track/SetTrackScoreDialog.kt | 79 ++ .../ui/anime/track/SetTrackStatusDialog.kt | 64 + .../track/SetTrackWatchingDatesDialog.kt | 86 ++ .../tachiyomi/ui/anime/track/TrackAdapter.kt | 49 + .../tachiyomi/ui/anime/track/TrackHolder.kt | 66 + .../tachiyomi/ui/anime/track/TrackItem.kt | 6 + .../ui/anime/track/TrackSearchAdapter.kt | 80 ++ .../ui/anime/track/TrackSearchDialog.kt | 138 +++ .../tachiyomi/ui/anime/track/TrackSheet.kt | 143 +++ .../ui/base/presenter/BasePresenter.kt | 9 + .../browse/migration/AnimeMigrationFlags.kt | 38 + .../manga/MigrationMangaController.kt | 4 +- .../migration/search/AnimeSearchController.kt | 131 ++ .../migration/search/AnimeSearchPresenter.kt | 169 +++ .../search/AnimeSourceSearchController.kt | 39 + .../AnimeSourceComfortableGridHolder.kt | 56 + .../source/browse/AnimeSourceGridHolder.kt | 56 + .../browse/source/browse/AnimeSourceHolder.kt | 35 + .../browse/source/browse/AnimeSourceItem.kt | 91 ++ .../source/browse/AnimeSourceListHolder.kt | 64 + .../globalsearch/GlobalAnimeSearchAdapter.kt | 81 ++ .../GlobalAnimeSearchCardAdapter.kt | 27 + .../GlobalAnimeSearchCardHolder.kt | 56 + .../globalsearch/GlobalAnimeSearchCardItem.kt | 40 + .../GlobalAnimeSearchController.kt | 226 ++++ .../globalsearch/GlobalAnimeSearchHolder.kt | 110 ++ .../globalsearch/GlobalAnimeSearchItem.kt | 71 ++ .../GlobalAnimeSearchPresenter.kt | 273 +++++ .../tachiyomi/ui/library/LibraryController.kt | 6 +- .../kanade/tachiyomi/ui/main/MainActivity.kt | 9 +- .../ui/manga/info/MangaInfoHeaderAdapter.kt | 8 +- .../ui/recent/history/HistoryController.kt | 4 +- .../ui/recent/updates/UpdatesAdapter.kt | 4 +- .../ui/recent/updates/UpdatesController.kt | 11 +- .../ui/recent/updates/UpdatesHolder.kt | 4 +- .../ui/recent/updates/UpdatesItem.kt | 4 +- .../ui/watcher/ChapterLoadStrategy.kt | 46 + .../ui/watcher/PageIndicatorTextView.kt | 54 + .../ui/watcher/ReaderColorFilterView.kt | 36 + .../ui/watcher/ReaderNavigationOverlayView.kt | 119 ++ .../tachiyomi/ui/watcher/ReaderPageSheet.kt | 59 + .../tachiyomi/ui/watcher/ReaderSeekBar.kt | 44 + .../tachiyomi/ui/watcher/SaveImageNotifier.kt | 108 ++ .../tachiyomi/ui/watcher/WatcherActivity.kt | 116 +- .../tachiyomi/ui/watcher/WatcherPresenter.kt | 717 +++++++++++ .../ui/watcher/loader/DirectoryPageLoader.kt | 40 + .../ui/watcher/loader/DownloadPageLoader.kt | 46 + .../ui/watcher/loader/EpisodeLoader.kt | 93 ++ .../ui/watcher/loader/EpubPageLoader.kt | 55 + .../ui/watcher/loader/HttpPageLoader.kt | 244 ++++ .../tachiyomi/ui/watcher/loader/PageLoader.kt | 45 + .../ui/watcher/loader/RarPageLoader.kt | 88 ++ .../ui/watcher/loader/ZipPageLoader.kt | 65 + .../ui/watcher/model/EpisodeTransition.kt | 35 + .../tachiyomi/ui/watcher/model/InsertPage.kt | 10 + .../ui/watcher/model/ViewerEpisodes.kt | 20 + .../ui/watcher/model/WatcherEpisode.kt | 53 + .../tachiyomi/ui/watcher/model/WatcherPage.kt | 14 + .../ui/watcher/setting/OrientationType.kt | 43 + .../setting/ReaderColorFilterSettings.kt | 245 ++++ .../watcher/setting/ReaderGeneralSettings.kt | 50 + .../setting/ReaderReadingModeSettings.kt | 104 ++ .../ui/watcher/setting/ReaderSettingsSheet.kt | 63 + .../ui/watcher/setting/ReadingModeType.kt | 30 + .../tachiyomi/ui/watcher/viewer/BaseViewer.kt | 45 + .../viewer/GestureDetectorWithLongTap.kt | 74 ++ .../ui/watcher/viewer/MissingChapters.kt | 45 + .../ui/watcher/viewer/ReaderProgressBar.kt | 215 ++++ .../ui/watcher/viewer/ReaderTransitionView.kt | 105 ++ .../ui/watcher/viewer/ViewerConfig.kt | 93 ++ .../ui/watcher/viewer/ViewerNavigation.kt | 49 + .../viewer/navigation/EdgeNavigation.kt | 32 + .../viewer/navigation/KindlishNavigation.kt | 28 + .../watcher/viewer/navigation/LNavigation.kt | 36 + .../navigation/RightAndLeftNavigation.kt | 28 + .../ui/watcher/viewer/pager/Pager.kt | 109 ++ .../ui/watcher/viewer/pager/PagerButton.kt | 24 + .../ui/watcher/viewer/pager/PagerConfig.kt | 114 ++ .../watcher/viewer/pager/PagerPageHolder.kt | 514 ++++++++ .../viewer/pager/PagerTransitionHolder.kt | 147 +++ .../ui/watcher/viewer/pager/PagerViewer.kt | 394 ++++++ .../viewer/pager/PagerViewerAdapter.kt | 160 +++ .../ui/watcher/viewer/pager/PagerViewers.kt | 53 + .../watcher/viewer/webtoon/WebtoonAdapter.kt | 187 +++ .../viewer/webtoon/WebtoonBaseHolder.kt | 45 + .../watcher/viewer/webtoon/WebtoonConfig.kt | 75 ++ .../ui/watcher/viewer/webtoon/WebtoonFrame.kt | 79 ++ .../viewer/webtoon/WebtoonLayoutManager.kt | 55 + .../viewer/webtoon/WebtoonPageHolder.kt | 544 ++++++++ .../viewer/webtoon/WebtoonRecyclerView.kt | 324 +++++ .../webtoon/WebtoonSubsamplingImageView.kt | 20 + .../viewer/webtoon/WebtoonTransitionHolder.kt | 155 +++ .../watcher/viewer/webtoon/WebtoonViewer.kt | 335 +++++ .../tachiyomi/util/storage/AnimeFile.kt | 215 ++++ .../main/java/tachiyomi/source/AnimeSource.kt | 55 + app/src/main/res/layout/anime_controller.xml | 52 + .../main/res/layout/anime_episodes_header.xml | 39 + app/src/main/res/layout/anime_info_header.xml | 272 ++++ .../anime_source_comfortable_grid_item.xml | 114 ++ .../layout/anime_source_compact_grid_item.xml | 114 ++ .../res/layout/anime_source_list_item.xml | 101 ++ .../main/res/layout/episode_download_view.xml | 73 ++ app/src/main/res/layout/episodes_item.xml | 61 + .../layout/global_anime_search_controller.xml | 52 + .../global_anime_search_controller_card.xml | 84 ++ ...obal_anime_search_controller_card_item.xml | 61 + app/src/main/res/layout/watcher_activity.xml | 219 ++++ app/src/main/res/menu/anime.xml | 54 + app/src/main/res/menu/animelib.xml | 28 + app/src/main/res/menu/bottom_nav.xml | 4 + app/src/main/res/menu/episode_selection.xml | 54 + app/src/main/res/menu/watcher.xml | 19 + app/src/main/res/values/strings.xml | 22 + 144 files changed, 14433 insertions(+), 212 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/glide/AnimeThumbnail.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/source/AnimeCatalogueSource.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/source/AnimeSource.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/source/LocalAnimeSource.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeController.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimePresenter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/AnimeEpisodesHeaderAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/DeleteEpisodesDialog.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/DownloadCustomEpisodesDialog.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeDownloadView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeItem.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodesAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodesSettingsSheet.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/SetEpisodeSettingsDialog.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodeHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodeItem.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodesAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/info/AnimeCoverImageView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/info/AnimeInfoHeaderAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackEpisodesDialog.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackScoreDialog.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackStatusDialog.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackWatchingDatesDialog.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackItem.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackSearchAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackSearchDialog.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackSheet.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/AnimeMigrationFlags.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSearchController.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSearchPresenter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSourceSearchController.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceComfortableGridHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceGridHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceItem.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceListHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchCardAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchCardHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchCardItem.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchController.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchItem.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchPresenter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ChapterLoadStrategy.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/PageIndicatorTextView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderColorFilterView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderNavigationOverlayView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderPageSheet.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderSeekBar.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/SaveImageNotifier.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/WatcherPresenter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/DirectoryPageLoader.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/DownloadPageLoader.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/EpisodeLoader.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/EpubPageLoader.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/HttpPageLoader.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/PageLoader.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/RarPageLoader.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/ZipPageLoader.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/EpisodeTransition.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/InsertPage.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/ViewerEpisodes.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/WatcherEpisode.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/WatcherPage.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/OrientationType.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderColorFilterSettings.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderGeneralSettings.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderReadingModeSettings.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderSettingsSheet.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReadingModeType.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/BaseViewer.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/GestureDetectorWithLongTap.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/MissingChapters.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ReaderProgressBar.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ReaderTransitionView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ViewerConfig.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ViewerNavigation.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/EdgeNavigation.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/KindlishNavigation.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/LNavigation.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/RightAndLeftNavigation.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/Pager.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerButton.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerConfig.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerPageHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerTransitionHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerViewer.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerViewerAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerViewers.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonBaseHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonConfig.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonFrame.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonLayoutManager.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonPageHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonRecyclerView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonSubsamplingImageView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonTransitionHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonViewer.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/util/storage/AnimeFile.kt create mode 100644 app/src/main/java/tachiyomi/source/AnimeSource.kt create mode 100644 app/src/main/res/layout/anime_controller.xml create mode 100644 app/src/main/res/layout/anime_episodes_header.xml create mode 100644 app/src/main/res/layout/anime_info_header.xml create mode 100644 app/src/main/res/layout/anime_source_comfortable_grid_item.xml create mode 100644 app/src/main/res/layout/anime_source_compact_grid_item.xml create mode 100644 app/src/main/res/layout/anime_source_list_item.xml create mode 100644 app/src/main/res/layout/episode_download_view.xml create mode 100644 app/src/main/res/layout/episodes_item.xml create mode 100644 app/src/main/res/layout/global_anime_search_controller.xml create mode 100644 app/src/main/res/layout/global_anime_search_controller_card.xml create mode 100644 app/src/main/res/layout/global_anime_search_controller_card_item.xml create mode 100644 app/src/main/res/layout/watcher_activity.xml create mode 100644 app/src/main/res/menu/anime.xml create mode 100644 app/src/main/res/menu/animelib.xml create mode 100644 app/src/main/res/menu/episode_selection.xml create mode 100644 app/src/main/res/menu/watcher.xml diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/animelib/AnimelibUpdateNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/animelib/AnimelibUpdateNotifier.kt index e4554a692..05db82734 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/animelib/AnimelibUpdateNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/animelib/AnimelibUpdateNotifier.kt @@ -13,6 +13,7 @@ import com.bumptech.glide.Glide import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Anime import eu.kanade.tachiyomi.data.database.models.Episode +import eu.kanade.tachiyomi.data.glide.toAnimeThumbnail import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper @@ -302,7 +303,7 @@ class AnimelibUpdateNotifier(private val context: Context) { } companion object { - private const val NOTIF_MAX_CHAPTERS = 5 + private const val NOTIF_MAX_EPISODES = 5 private const val NOTIF_TITLE_MAX_LEN = 45 private const val NOTIF_ICON_SIZE = 192 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/AnimeTrackQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/AnimeTrackQueries.kt index bd695af4d..8ab25c031 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/AnimeTrackQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/AnimeTrackQueries.kt @@ -4,41 +4,41 @@ import com.pushtorefresh.storio.sqlite.queries.DeleteQuery import com.pushtorefresh.storio.sqlite.queries.Query import eu.kanade.tachiyomi.data.database.DbProvider import eu.kanade.tachiyomi.data.database.models.Anime -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.database.tables.TrackTable +import eu.kanade.tachiyomi.data.database.models.AnimeTrack +import eu.kanade.tachiyomi.data.database.tables.AnimeTrackTable import eu.kanade.tachiyomi.data.track.TrackService interface AnimeTrackQueries : DbProvider { fun getTracks() = db.get() - .listOfObjects(Track::class.java) + .listOfObjects(AnimeTrack::class.java) .withQuery( Query.builder() - .table(TrackTable.TABLE) + .table(AnimeTrackTable.TABLE) .build() ) .prepare() fun getTracks(anime: Anime) = db.get() - .listOfObjects(Track::class.java) + .listOfObjects(AnimeTrack::class.java) .withQuery( Query.builder() - .table(TrackTable.TABLE) - .where("${TrackTable.COL_MANGA_ID} = ?") + .table(AnimeTrackTable.TABLE) + .where("${AnimeTrackTable.COL_ANIME_ID} = ?") .whereArgs(anime.id) .build() ) .prepare() - fun insertTrack(track: Track) = db.put().`object`(track).prepare() + fun insertTrack(track: AnimeTrack) = db.put().`object`(track).prepare() - fun insertTracks(tracks: List) = db.put().objects(tracks).prepare() + fun insertTracks(tracks: List) = db.put().objects(tracks).prepare() fun deleteTrackForAnime(anime: Anime, sync: TrackService) = db.delete() .byQuery( DeleteQuery.builder() - .table(TrackTable.TABLE) - .where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?") + .table(AnimeTrackTable.TABLE) + .where("${AnimeTrackTable.COL_ANIME_ID} = ? AND ${AnimeTrackTable.COL_SYNC_ID} = ?") .whereArgs(anime.id, sync.id) .build() ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/glide/AnimeThumbnail.kt b/app/src/main/java/eu/kanade/tachiyomi/data/glide/AnimeThumbnail.kt new file mode 100644 index 000000000..9618051e6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/glide/AnimeThumbnail.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.data.glide + +import com.bumptech.glide.load.Key +import eu.kanade.tachiyomi.data.database.models.Anime +import java.security.MessageDigest + +data class AnimeThumbnail(val anime: Anime, val coverLastModified: Long) : Key { + val key = anime.url + coverLastModified + + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + messageDigest.update(key.toByteArray(Key.CHARSET)) + } +} + +fun Anime.toAnimeThumbnail() = AnimeThumbnail(this, cover_last_modified) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index f210d5e3f..f7e1c8de3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -20,8 +20,10 @@ import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.ui.anime.AnimeController import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.reader.ReaderActivity +import eu.kanade.tachiyomi.ui.watcher.WatcherActivity import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.getUriCompat @@ -32,6 +34,8 @@ import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import java.io.File import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID +import eu.kanade.tachiyomi.data.animelib.AnimelibUpdateService +import eu.kanade.tachiyomi.data.library.LibraryUpdateService /** * Global [BroadcastReceiver] that runs on UI thread @@ -223,6 +227,17 @@ class NotificationReceiver : BroadcastReceiver() { Handler().post { dismissNotification(context, notificationId) } } + /** + * Method called when user wants to stop a library update + * + * @param context context of application + * @param notificationId id of notification + */ + private fun cancelAnimelibUpdate(context: Context, notificationId: Int) { + AnimelibUpdateService.stop(context) + Handler().post { dismissNotification(context, notificationId) } + } + /** * Method called when user wants to mark manga chapters as read * @@ -418,7 +433,7 @@ class NotificationReceiver : BroadcastReceiver() { * @param episode episode that needs to be opened */ internal fun openEpisodePendingActivity(context: Context, anime: Anime, episode: Episode): PendingIntent { - val newIntent = ReaderActivity.newIntent(context, anime, episode) + val newIntent = WatcherActivity.newIntent(context, anime, episode) return PendingIntent.getActivity(context, anime.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt index 97a4b2ef3..36db11437 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt @@ -25,6 +25,7 @@ object Notifications { */ const val CHANNEL_LIBRARY = "library_channel" const val ID_LIBRARY_PROGRESS = -101 + const val ID_ANIMELIB_PROGRESS = -1337101 const val ID_LIBRARY_ERROR = -102 /** @@ -43,7 +44,9 @@ object Notifications { */ const val CHANNEL_NEW_CHAPTERS = "new_chapters_channel" const val ID_NEW_CHAPTERS = -301 + const val ID_NEW_EPISODES = -1337301 const val GROUP_NEW_CHAPTERS = "eu.kanade.tachiyomi.NEW_CHAPTERS" + const val GROUP_NEW_EPISODES = "eu.kanade.tachiyomi.NEW_EPISODES" /** * Notification channel and ids used by the library updater. 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 c096bf7f6..3636b372d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -91,6 +91,8 @@ object PreferenceKeys { const val jumpToChapters = "jump_to_chapters" + const val jumpToEpisodes = "jump_to_episodes" + const val updateOnlyNonCompleted = "pref_update_only_non_completed_key" const val autoUpdateTrack = "pref_auto_update_manga_sync_key" 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 cda4b3160..5a47bed15 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -130,6 +130,8 @@ class PreferencesHelper(val context: Context) { fun readerTheme() = flowPrefs.getInt(Keys.readerTheme, 1) + fun watcherTheme() = flowPrefs.getInt(Keys.readerTheme, 1) + fun alwaysShowChapterTransition() = flowPrefs.getBoolean(Keys.alwaysShowChapterTransition, true) fun cropBorders() = flowPrefs.getBoolean(Keys.cropBorders, false) @@ -164,6 +166,8 @@ class PreferencesHelper(val context: Context) { fun jumpToChapters() = prefs.getBoolean(Keys.jumpToChapters, false) + fun jumpToEpisodes() = prefs.getBoolean(Keys.jumpToEpisodes, false) + fun updateOnlyNonCompleted() = prefs.getBoolean(Keys.updateOnlyNonCompleted, false) fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt index 972685909..28ee5d21a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt @@ -5,6 +5,7 @@ import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.annotation.StringRes import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.database.models.AnimeTrack import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.NetworkHelper @@ -46,16 +47,24 @@ abstract class TrackService(val id: Int) { abstract fun displayScore(track: Track): String + abstract fun displayScore(track: AnimeTrack): String + abstract suspend fun add(track: Track): Track abstract suspend fun update(track: Track): Track + abstract suspend fun updateAnime(track: AnimeTrack): AnimeTrack + abstract suspend fun bind(track: Track): Track + abstract suspend fun bindAnime(track: AnimeTrack): AnimeTrack + abstract suspend fun search(query: String): List abstract suspend fun refresh(track: Track): Track + abstract suspend fun refreshAnime(track: AnimeTrack): AnimeTrack + abstract suspend fun login(username: String, password: String) @CallSuper diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/AnimeCatalogueSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/AnimeCatalogueSource.kt new file mode 100644 index 000000000..e5c8392e9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/AnimeCatalogueSource.kt @@ -0,0 +1,46 @@ +package eu.kanade.tachiyomi.source + +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.AnimesPage +import rx.Observable + +interface AnimeCatalogueSource : AnimeSource { + + /** + * An ISO 639-1 compliant language code (two letters in lower case). + */ + override val lang: String + + /** + * Whether the source has support for latest updates. + */ + val supportsLatest: Boolean + + /** + * Returns an observable containing a page with a list of anime. + * + * @param page the page number to retrieve. + */ + fun fetchPopularAnime(page: Int): Observable + + /** + * Returns an observable containing a page with a list of anime. + * + * @param page the page number to retrieve. + * @param query the search query. + * @param filters the list of filters to apply. + */ + fun fetchSearchAnime(page: Int, query: String, filters: FilterList): Observable + + /** + * Returns an observable containing a page with a list of latest anime updates. + * + * @param page the page number to retrieve. + */ + fun fetchLatestUpdates(page: Int): Observable + + /** + * Returns the list of filters for the source. + */ + fun getFilterList(): FilterList +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/AnimeSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/AnimeSource.kt new file mode 100644 index 000000000..c442d5695 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/AnimeSource.kt @@ -0,0 +1,88 @@ +package eu.kanade.tachiyomi.source + +import android.graphics.drawable.Drawable +import eu.kanade.tachiyomi.data.database.models.Episode +import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.util.lang.awaitSingle +import rx.Observable +import tachiyomi.source.model.EpisodeInfo +import tachiyomi.source.model.AnimeInfo +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * A basic interface for creating a source. It could be an online source, a local source, etc... + */ +interface AnimeSource : tachiyomi.source.AnimeSource { + + /** + * Id for the source. Must be unique. + */ + override val id: Long + + /** + * Name of the source. + */ + override val name: String + + override val lang: String + get() = "" + + /** + * Returns an observable with the updated details for a anime. + * + * @param anime the anime to update. + */ + @Deprecated("Use getAnimeDetails instead") + fun fetchAnimeDetails(anime: SAnime): Observable + + /** + * Returns an observable with all the available chapters for a anime. + * + * @param anime the anime to update. + */ + @Deprecated("Use getEpisodeList instead") + fun fetchEpisodeList(anime: SAnime): Observable> + + /** + * Returns an observable with the list of pages a chapter has. + * + * @param chapter the chapter. + */ + @Deprecated("Use getPageList instead") + fun fetchPageList(episode: SEpisode): Observable> + + /** + * [1.x API] Get the updated details for a anime. + */ + @Suppress("DEPRECATION") + override suspend fun getAnimeDetails(anime: AnimeInfo): AnimeInfo { + val sAnime = anime.toSAnime() + val networkAnime = fetchAnimeDetails(sAnime).awaitSingle() + sAnime.copyFrom(networkAnime) + return sAnime.toAnimeInfo() + } + + /** + * [1.x API] Get all the available chapters for a anime. + */ + @Suppress("DEPRECATION") + override suspend fun getEpisodeList(anime: AnimeInfo): List { + return fetchEpisodeList(anime.toSAnime()).awaitSingle() + .map { it.toEpisodeInfo() } + } + + /** + * [1.x API] Get the list of pages a chapter has. + */ + @Suppress("DEPRECATION") + override suspend fun getPageList(episode: EpisodeInfo): List { + return fetchPageList(episode.toSEpisode()).awaitSingle() + .map { it.toPageUrl() } + } +} + +fun Source.iconAnime(): Drawable? = Injekt.get().getAppIconForSource(this) + +fun Source.getPreferenceKeyAnime(): String = "source_$id" diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt index 21d5434a5..c22b0390f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt @@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.source import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage -import eu.kanade.tachiyomi.source.model.AnimesPage import rx.Observable interface CatalogueSource : Source { @@ -24,13 +23,6 @@ interface CatalogueSource : Source { */ fun fetchPopularManga(page: Int): Observable - /** - * Returns an observable containing a page with a list of anime. - * - * @param page the page number to retrieve. - */ - fun fetchPopularAnime(page: Int): Observable - /** * Returns an observable containing a page with a list of manga. * @@ -40,15 +32,6 @@ interface CatalogueSource : Source { */ fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable - /** - * Returns an observable containing a page with a list of anime. - * - * @param page the page number to retrieve. - * @param query the search query. - * @param filters the list of filters to apply. - */ - fun fetchSearchAnime(page: Int, query: String, filters: FilterList): Observable - /** * Returns an observable containing a page with a list of latest manga updates. * @@ -56,15 +39,8 @@ interface CatalogueSource : Source { */ fun fetchLatestUpdates(page: Int): Observable - /** - * Returns an observable containing a page with a list of latest anime updates. - * - * @param page the page number to retrieve. - */ - fun fetchLatestAnimeUpdates(page: Int): Observable - /** * Returns the list of filters for the source. */ fun getFilterList(): FilterList -} +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalAnimeSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalAnimeSource.kt new file mode 100644 index 000000000..89cdfcba6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/LocalAnimeSource.kt @@ -0,0 +1,324 @@ +package eu.kanade.tachiyomi.source + +import android.content.Context +import com.github.junrar.Archive +import com.google.gson.JsonParser +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.AnimesPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SEpisode +import eu.kanade.tachiyomi.source.model.SAnime +import eu.kanade.tachiyomi.util.episode.EpisodeRecognition +import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder +import eu.kanade.tachiyomi.util.storage.DiskUtil +import eu.kanade.tachiyomi.util.storage.AnimeFile +import eu.kanade.tachiyomi.util.system.ImageUtil +import rx.Observable +import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.util.Locale +import java.util.concurrent.TimeUnit +import java.util.zip.ZipFile + +class LocalAnimeSource(private val context: Context) : AnimeCatalogueSource { + companion object { + const val ID = 0L + const val HELP_URL = "https://tachiyomi.org/help/guides/local-anime/" + + private const val COVER_NAME = "cover.jpg" + private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub") + + private val POPULAR_FILTERS = FilterList(OrderBy()) + private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) }) + private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) + + fun updateCover(context: Context, anime: SAnime, input: InputStream): File? { + val dir = getBaseDirectories(context).firstOrNull() + if (dir == null) { + input.close() + return null + } + val cover = File("${dir.absolutePath}/${anime.url}", COVER_NAME) + + // It might not exist if using the external SD card + cover.parentFile?.mkdirs() + input.use { + cover.outputStream().use { + input.copyTo(it) + } + } + return cover + } + + private fun getBaseDirectories(context: Context): List { + val c = context.getString(R.string.app_name) + File.separator + "local" + return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) } + } + } + + override val id = ID + override val name = context.getString(R.string.local_source) + override val lang = "" + override val supportsLatest = true + + override fun toString() = context.getString(R.string.local_source) + + override fun fetchPopularAnime(page: Int) = fetchSearchAnime(page, "", POPULAR_FILTERS) + + override fun fetchSearchAnime(page: Int, query: String, filters: FilterList): Observable { + val baseDirs = getBaseDirectories(context) + + val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L + var animeDirs = baseDirs + .asSequence() + .mapNotNull { it.listFiles()?.toList() } + .flatten() + .filter { it.isDirectory } + .filterNot { it.name.startsWith('.') } + .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } + .distinctBy { it.name } + + val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state + when (state?.index) { + 0 -> { + animeDirs = if (state.ascending) { + animeDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) } + } else { + animeDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) } + } + } + 1 -> { + animeDirs = if (state.ascending) { + animeDirs.sortedBy(File::lastModified) + } else { + animeDirs.sortedByDescending(File::lastModified) + } + } + } + + val animes = animeDirs.map { animeDir -> + SAnime.create().apply { + title = animeDir.name + url = animeDir.name + + // Try to find the cover + for (dir in baseDirs) { + val cover = File("${dir.absolutePath}/$url", COVER_NAME) + if (cover.exists()) { + thumbnail_url = cover.absolutePath + break + } + } + + val episodes = fetchEpisodeList(this).toBlocking().first() + if (episodes.isNotEmpty()) { + val episode = episodes.last() + val format = getFormat(episode) + if (format is Format.Anime) { + AnimeFile(format.file).use { epub -> + epub.fillAnimeMetadata(this) + } + } + + // Copy the cover from the first episode found. + if (thumbnail_url == null) { + try { + val dest = updateCover(episode, this) + thumbnail_url = dest?.absolutePath + } catch (e: Exception) { + Timber.e(e) + } + } + } + } + } + + return Observable.just(AnimesPage(animes.toList(), false)) + } + + override fun fetchLatestUpdates(page: Int) = fetchSearchAnime(page, "", LATEST_FILTERS) + + override fun fetchAnimeDetails(anime: SAnime): Observable { + getBaseDirectories(context) + .asSequence() + .mapNotNull { File(it, anime.url).listFiles()?.toList() } + .flatten() + .firstOrNull { it.extension == "json" } + ?.apply { + val reader = this.inputStream().bufferedReader() + val json = JsonParser.parseReader(reader).asJsonObject + + anime.title = json["title"]?.asString ?: anime.title + anime.author = json["author"]?.asString ?: anime.author + anime.artist = json["artist"]?.asString ?: anime.artist + anime.description = json["description"]?.asString ?: anime.description + anime.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString } + ?: anime.genre + anime.status = json["status"]?.asInt ?: anime.status + } + + return Observable.just(anime) + } + + override fun fetchEpisodeList(anime: SAnime): Observable> { + val episodes = getBaseDirectories(context) + .asSequence() + .mapNotNull { File(it, anime.url).listFiles()?.toList() } + .flatten() + .filter { it.isDirectory || isSupportedFile(it.extension) } + .map { episodeFile -> + SEpisode.create().apply { + url = "${anime.url}/${episodeFile.name}" + name = if (episodeFile.isDirectory) { + episodeFile.name + } else { + episodeFile.nameWithoutExtension + } + date_upload = episodeFile.lastModified() + + val format = getFormat(this) + if (format is Format.Anime) { + AnimeFile(format.file).use { epub -> + epub.fillEpisodeMetadata(this) + } + } + + val chapNameCut = stripAnimeTitle(name, anime.title) + if (chapNameCut.isNotEmpty()) name = chapNameCut + EpisodeRecognition.parseEpisodeNumber(this, anime) + } + } + .sortedWith( + Comparator { c1, c2 -> + val c = c2.episode_number.compareTo(c1.episode_number) + if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c + } + ) + .toList() + + return Observable.just(episodes) + } + + /** + * Strips the anime title from a episode name, matching only based on alphanumeric and whitespace + * characters. + */ + private fun stripAnimeTitle(episodeName: String, animeTitle: String): String { + var episodeNameIndex = 0 + var animeTitleIndex = 0 + while (episodeNameIndex < episodeName.length && animeTitleIndex < animeTitle.length) { + val episodeChar = episodeName[episodeNameIndex] + val animeChar = animeTitle[animeTitleIndex] + if (!episodeChar.equals(animeChar, true)) { + val invalidEpisodeChar = !episodeChar.isLetterOrDigit() && !episodeChar.isWhitespace() + val invalidAnimeChar = !animeChar.isLetterOrDigit() && !animeChar.isWhitespace() + + if (!invalidEpisodeChar && !invalidAnimeChar) { + return episodeName + } + + if (invalidEpisodeChar) { + episodeNameIndex++ + } + + if (invalidAnimeChar) { + animeTitleIndex++ + } + } else { + episodeNameIndex++ + animeTitleIndex++ + } + } + + return episodeName.substring(episodeNameIndex).trimStart(' ', '-', '_', ',', ':') + } + + override fun fetchPageList(episode: SEpisode): Observable> { + return Observable.error(Exception("Unused")) + } + + private fun isSupportedFile(extension: String): Boolean { + return extension.toLowerCase() in SUPPORTED_ARCHIVE_TYPES + } + + fun getFormat(episode: SEpisode): Format { + val baseDirs = getBaseDirectories(context) + + for (dir in baseDirs) { + val chapFile = File(dir, episode.url) + if (!chapFile.exists()) continue + + return getFormat(chapFile) + } + throw Exception("Episode not found") + } + + private fun getFormat(file: File): Format { + val extension = file.extension + return if (file.isDirectory) { + Format.Directory(file) + } else if (extension.equals("zip", true) || extension.equals("cbz", true)) { + Format.Zip(file) + } else if (extension.equals("rar", true) || extension.equals("cbr", true)) { + Format.Rar(file) + } else if (extension.equals("epub", true)) { + Format.Anime(file) + } else { + throw Exception("Invalid episode format") + } + } + + private fun updateCover(episode: SEpisode, anime: SAnime): File? { + return when (val format = getFormat(episode)) { + is Format.Directory -> { + val entry = format.file.listFiles() + ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } + ?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } + + entry?.let { updateCover(context, anime, it.inputStream()) } + } + is Format.Zip -> { + ZipFile(format.file).use { zip -> + val entry = zip.entries().toList() + .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } + .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } + + entry?.let { updateCover(context, anime, zip.getInputStream(it)) } + } + } + is Format.Rar -> { + Archive(format.file).use { archive -> + val entry = archive.fileHeaders + .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } + .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } + + entry?.let { updateCover(context, anime, archive.getInputStream(it)) } + } + } + is Format.Anime -> { + AnimeFile(format.file).use { epub -> + val entry = epub.getImagesFromPages() + .firstOrNull() + ?.let { epub.getEntry(it) } + + entry?.let { updateCover(context, anime, epub.getInputStream(it)) } + } + } + } + } + + private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Selection(0, true)) + + override fun getFilterList() = FilterList(OrderBy()) + + sealed class Format { + data class Directory(val file: File) : Format() + data class Zip(val file: File) : Format() + data class Rar(val file: File) : Format() + data class Anime(val file: File) : Format() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt index 90099cbba..bc7d0121d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt @@ -74,13 +74,13 @@ class LocalSource(private val context: Context) : CatalogueSource { val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L var mangaDirs = baseDirs - .asSequence() - .mapNotNull { it.listFiles()?.toList() } - .flatten() - .filter { it.isDirectory } - .filterNot { it.name.startsWith('.') } - .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } - .distinctBy { it.name } + .asSequence() + .mapNotNull { it.listFiles()?.toList() } + .flatten() + .filter { it.isDirectory } + .filterNot { it.name.startsWith('.') } + .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } + .distinctBy { it.name } val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state when (state?.index) { @@ -144,61 +144,61 @@ class LocalSource(private val context: Context) : CatalogueSource { override fun fetchMangaDetails(manga: SManga): Observable { getBaseDirectories(context) - .asSequence() - .mapNotNull { File(it, manga.url).listFiles()?.toList() } - .flatten() - .firstOrNull { it.extension == "json" } - ?.apply { - val reader = this.inputStream().bufferedReader() - val json = JsonParser.parseReader(reader).asJsonObject + .asSequence() + .mapNotNull { File(it, manga.url).listFiles()?.toList() } + .flatten() + .firstOrNull { it.extension == "json" } + ?.apply { + val reader = this.inputStream().bufferedReader() + val json = JsonParser.parseReader(reader).asJsonObject - manga.title = json["title"]?.asString ?: manga.title - manga.author = json["author"]?.asString ?: manga.author - manga.artist = json["artist"]?.asString ?: manga.artist - manga.description = json["description"]?.asString ?: manga.description - manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString } - ?: manga.genre - manga.status = json["status"]?.asInt ?: manga.status - } + manga.title = json["title"]?.asString ?: manga.title + manga.author = json["author"]?.asString ?: manga.author + manga.artist = json["artist"]?.asString ?: manga.artist + manga.description = json["description"]?.asString ?: manga.description + manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString } + ?: manga.genre + manga.status = json["status"]?.asInt ?: manga.status + } return Observable.just(manga) } override fun fetchChapterList(manga: SManga): Observable> { val chapters = getBaseDirectories(context) - .asSequence() - .mapNotNull { File(it, manga.url).listFiles()?.toList() } - .flatten() - .filter { it.isDirectory || isSupportedFile(it.extension) } - .map { chapterFile -> - SChapter.create().apply { - url = "${manga.url}/${chapterFile.name}" - name = if (chapterFile.isDirectory) { - chapterFile.name - } else { - chapterFile.nameWithoutExtension - } - date_upload = chapterFile.lastModified() - - val format = getFormat(this) - if (format is Format.Epub) { - EpubFile(format.file).use { epub -> - epub.fillChapterMetadata(this) + .asSequence() + .mapNotNull { File(it, manga.url).listFiles()?.toList() } + .flatten() + .filter { it.isDirectory || isSupportedFile(it.extension) } + .map { chapterFile -> + SChapter.create().apply { + url = "${manga.url}/${chapterFile.name}" + name = if (chapterFile.isDirectory) { + chapterFile.name + } else { + chapterFile.nameWithoutExtension } - } + date_upload = chapterFile.lastModified() - val chapNameCut = stripMangaTitle(name, manga.title) - if (chapNameCut.isNotEmpty()) name = chapNameCut - ChapterRecognition.parseChapterNumber(this, manga) + val format = getFormat(this) + if (format is Format.Epub) { + EpubFile(format.file).use { epub -> + epub.fillChapterMetadata(this) + } + } + + val chapNameCut = stripMangaTitle(name, manga.title) + if (chapNameCut.isNotEmpty()) name = chapNameCut + ChapterRecognition.parseChapterNumber(this, manga) + } } - } - .sortedWith( - Comparator { c1, c2 -> - val c = c2.chapter_number.compareTo(c1.chapter_number) - if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c - } - ) - .toList() + .sortedWith( + Comparator { c1, c2 -> + val c = c2.chapter_number.compareTo(c1.chapter_number) + if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c + } + ) + .toList() return Observable.just(chapters) } @@ -276,16 +276,16 @@ class LocalSource(private val context: Context) : CatalogueSource { return when (val format = getFormat(chapter)) { is Format.Directory -> { val entry = format.file.listFiles() - ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } - ?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } + ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } + ?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } entry?.let { updateCover(context, manga, it.inputStream()) } } is Format.Zip -> { ZipFile(format.file).use { zip -> val entry = zip.entries().toList() - .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } - .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } + .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } + .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } entry?.let { updateCover(context, manga, zip.getInputStream(it)) } } @@ -293,8 +293,8 @@ class LocalSource(private val context: Context) : CatalogueSource { is Format.Rar -> { Archive(format.file).use { archive -> val entry = archive.fileHeaders - .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } - .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } + .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } + .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } entry?.let { updateCover(context, manga, archive.getInputStream(it)) } } @@ -302,8 +302,8 @@ class LocalSource(private val context: Context) : CatalogueSource { is Format.Epub -> { EpubFile(format.file).use { epub -> val entry = epub.getImagesFromPages() - .firstOrNull() - ?.let { epub.getEntry(it) } + .firstOrNull() + ?.let { epub.getEntry(it) } entry?.let { updateCover(context, manga, epub.getInputStream(it)) } } @@ -321,4 +321,4 @@ class LocalSource(private val context: Context) : CatalogueSource { data class Rar(val file: File) : Format() data class Epub(val file: File) : Format() } -} +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt index 816838cfe..c7c1097ea 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt @@ -4,9 +4,7 @@ import android.graphics.drawable.Drawable import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter -import eu.kanade.tachiyomi.source.model.SEpisode import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.source.model.SAnime import eu.kanade.tachiyomi.source.model.toChapterInfo import eu.kanade.tachiyomi.source.model.toMangaInfo import eu.kanade.tachiyomi.source.model.toPageUrl @@ -45,14 +43,6 @@ interface Source : tachiyomi.source.Source { @Deprecated("Use getMangaDetails instead") fun fetchMangaDetails(manga: SManga): Observable - /** - * Returns an observable with the updated details for a manga. - * - * @param anime the manga to update. - */ - @Deprecated("Use getAnimeDetails instead") - fun fetchAnimeDetails(anime: SAnime): Observable - /** * Returns an observable with all the available chapters for a manga. * @@ -61,14 +51,6 @@ interface Source : tachiyomi.source.Source { @Deprecated("Use getChapterList instead") fun fetchChapterList(manga: SManga): Observable> - /** - * Returns an observable with all the available chapters for a manga. - * - * @param anime the manga to update. - */ - @Deprecated("Use getEpisodeList instead") - fun fetchEpisodeList(anime: SAnime): Observable> - /** * Returns an observable with the list of pages a chapter has. * @@ -77,14 +59,6 @@ interface Source : tachiyomi.source.Source { @Deprecated("Use getPageList instead") fun fetchPageList(chapter: SChapter): Observable> - /** - * Returns an observable with the list of pages a episode has. - * - * @param episode the episode. - */ - @Deprecated("Use getPageList instead") - fun fetchAnimePageList(episode: SEpisode): Observable> - /** * [1.x API] Get the updated details for a manga. */ @@ -102,7 +76,7 @@ interface Source : tachiyomi.source.Source { @Suppress("DEPRECATION") override suspend fun getChapterList(manga: MangaInfo): List { return fetchChapterList(manga.toSManga()).awaitSingle() - .map { it.toChapterInfo() } + .map { it.toChapterInfo() } } /** @@ -111,10 +85,10 @@ interface Source : tachiyomi.source.Source { @Suppress("DEPRECATION") override suspend fun getPageList(chapter: ChapterInfo): List { return fetchPageList(chapter.toSChapter()).awaitSingle() - .map { it.toPageUrl() } + .map { it.toPageUrl() } } } fun Source.icon(): Drawable? = Injekt.get().getAppIconForSource(this) -fun Source.getPreferenceKey(): String = "source_$id" +fun Source.getPreferenceKey(): String = "source_$id" \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeController.kt new file mode 100644 index 000000000..1e272ceb0 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeController.kt @@ -0,0 +1,1089 @@ +package eu.kanade.tachiyomi.ui.anime + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.app.Activity +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import androidx.core.graphics.blue +import androidx.core.graphics.green +import androidx.core.graphics.red +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.bluelinelabs.conductor.ControllerChangeHandler +import com.bluelinelabs.conductor.ControllerChangeType +import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton +import com.google.android.material.snackbar.Snackbar +import dev.chrisbanes.insetter.applyInsetter +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.SelectableAdapter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.cache.AnimeCoverCache +import eu.kanade.tachiyomi.data.database.AnimeDatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Episode +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.data.download.DownloadService +import eu.kanade.tachiyomi.data.download.model.AnimeDownload +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.databinding.AnimeControllerBinding +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.online.AnimeHttpSource +import eu.kanade.tachiyomi.ui.base.controller.FabController +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.ToolbarLiftOnScrollController +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.browse.migration.search.AnimeSearchController +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.ui.animelib.ChangeAnimeCategoriesDialog +import eu.kanade.tachiyomi.ui.animelib.ChangeAnimeCoverDialog +import eu.kanade.tachiyomi.ui.animelib.AnimelibController +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.ui.anime.episode.EpisodeItem +import eu.kanade.tachiyomi.ui.anime.episode.EpisodesAdapter +import eu.kanade.tachiyomi.ui.anime.episode.EpisodesSettingsSheet +import eu.kanade.tachiyomi.ui.anime.episode.DeleteEpisodesDialog +import eu.kanade.tachiyomi.ui.anime.episode.DownloadCustomEpisodesDialog +import eu.kanade.tachiyomi.ui.anime.episode.AnimeEpisodesHeaderAdapter +import eu.kanade.tachiyomi.ui.anime.episode.base.BaseEpisodesAdapter +import eu.kanade.tachiyomi.ui.anime.info.AnimeInfoHeaderAdapter +import eu.kanade.tachiyomi.ui.anime.track.TrackItem +import eu.kanade.tachiyomi.ui.anime.track.TrackSearchDialog +import eu.kanade.tachiyomi.ui.anime.track.TrackSheet +import eu.kanade.tachiyomi.ui.watcher.WatcherActivity +import eu.kanade.tachiyomi.ui.recent.history.HistoryController +import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController +import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import eu.kanade.tachiyomi.util.episode.NoEpisodesException +import eu.kanade.tachiyomi.util.hasCustomCover +import eu.kanade.tachiyomi.util.lang.launchUI +import eu.kanade.tachiyomi.util.system.getResourceColor +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.view.getCoordinates +import eu.kanade.tachiyomi.util.view.shrinkOnScroll +import eu.kanade.tachiyomi.util.view.snack +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.recyclerview.scrollEvents +import reactivecircus.flowbinding.swiperefreshlayout.refreshes +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import java.util.ArrayDeque +import kotlin.math.min + +class AnimeController : + NucleusController, + ToolbarLiftOnScrollController, + FabController, + ActionMode.Callback, + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener, + BaseEpisodesAdapter.OnEpisodeClickListener, + ChangeAnimeCoverDialog.Listener, + ChangeAnimeCategoriesDialog.Listener, + DownloadCustomEpisodesDialog.Listener, + DeleteEpisodesDialog.Listener { + + constructor(anime: Anime?, fromSource: Boolean = false) : super( + bundleOf( + MANGA_EXTRA to (anime?.id ?: 0), + FROM_SOURCE_EXTRA to fromSource + ) + ) { + this.anime = anime + if (anime != null) { + source = Injekt.get().getOrStub(anime.source) + } + } + + constructor(animeId: Long) : this( + Injekt.get().getAnime(animeId).executeAsBlocking() + ) + + @Suppress("unused") + constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA)) + + var anime: Anime? = null + private set + + var source: Source? = null + private set + + private val fromSource = args.getBoolean(FROM_SOURCE_EXTRA, false) + + private val preferences: PreferencesHelper by injectLazy() + private val coverCache: AnimeCoverCache by injectLazy() + + private val toolbarTextColor by lazy { view!!.context.getResourceColor(R.attr.colorOnPrimary) } + + private var animeInfoAdapter: AnimeInfoHeaderAdapter? = null + private var episodesHeaderAdapter: AnimeEpisodesHeaderAdapter? = null + private var episodesAdapter: EpisodesAdapter? = null + + // Sheet containing filter/sort/display items. + private var settingsSheet: EpisodesSettingsSheet? = null + + private var actionFab: ExtendedFloatingActionButton? = null + private var actionFabScrollListener: RecyclerView.OnScrollListener? = null + + // Snackbar to add anime to animelib after downloading episode(s) + private var addSnackbar: Snackbar? = null + + /** + * Action mode for multiple selection. + */ + private var actionMode: ActionMode? = null + + /** + * Selected items. Used to restore selections after a rotation. + */ + private val selectedEpisodes = mutableSetOf() + + private val isLocalSource by lazy { presenter.source.id == LocalSource.ID } + + private var lastClickPositionStack = ArrayDeque(listOf(-1)) + + private var isRefreshingInfo = false + private var isRefreshingEpisodes = false + + private var trackSheet: TrackSheet? = null + + init { + setHasOptionsMenu(true) + } + + override fun getTitle(): String? { + return anime?.title + } + + override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { + super.onChangeStarted(handler, type) + + // Hide toolbar title on enter + if (type.isEnter) { + updateToolbarTitleAlpha() + } + } + + override fun onChangeEnded(handler: ControllerChangeHandler, type: ControllerChangeType) { + super.onChangeEnded(handler, type) + if (anime == null || source == null) { + activity?.toast(R.string.manga_not_in_db) + router.popController(this) + } + } + + override fun createPresenter(): AnimePresenter { + return AnimePresenter( + anime!!, + source!! + ) + } + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + binding = AnimeControllerBinding.inflate(inflater) + binding.recycler.applyInsetter { + type(navigationBars = true) { + padding() + } + } + binding.actionToolbar.applyInsetter { + type(navigationBars = true) { + margin(bottom = true) + } + } + return binding.root + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + if (anime == null || source == null) return + + // Init RecyclerView and adapter + animeInfoAdapter = AnimeInfoHeaderAdapter(this, fromSource) + episodesHeaderAdapter = AnimeEpisodesHeaderAdapter(this) + episodesAdapter = EpisodesAdapter(this, view.context) + + binding.recycler.adapter = ConcatAdapter(animeInfoAdapter, episodesHeaderAdapter, episodesAdapter) + binding.recycler.layoutManager = LinearLayoutManager(view.context) + binding.recycler.setHasFixedSize(true) + episodesAdapter?.fastScroller = binding.fastScroller + + actionFabScrollListener = actionFab?.shrinkOnScroll(binding.recycler) + + // Skips directly to episodes list if navigated to from the animelib + binding.recycler.post { + if (!fromSource && preferences.jumpToEpisodes()) { + (binding.recycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(1, 0) + } + + // Delayed in case we need to jump to episodes + binding.recycler.post { + updateToolbarTitleAlpha() + } + } + + binding.recycler.scrollEvents() + .onEach { updateToolbarTitleAlpha() } + .launchIn(viewScope) + + binding.swipeRefresh.refreshes() + .onEach { + fetchAnimeInfoFromSource(manualFetch = true) + fetchEpisodesFromSource(manualFetch = true) + } + .launchIn(viewScope) + + (activity as? MainActivity)?.fixViewToBottom(binding.actionToolbar) + + settingsSheet = EpisodesSettingsSheet(router, presenter) { group -> + if (group is EpisodesSettingsSheet.Filter.FilterGroup) { + updateFilterIconState() + episodesAdapter?.notifyDataSetChanged() + } + } + + trackSheet = TrackSheet(this, anime!!) + + updateFilterIconState() + } + + private fun updateToolbarTitleAlpha(alpha: Int? = null) { + val calculatedAlpha = when { + // Specific alpha provided + alpha != null -> alpha + + // First item isn't in view, full opacity + ((binding.recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0) -> 255 + + // Based on scroll amount when first item is in view + else -> min(binding.recycler.computeVerticalScrollOffset(), 255) + } + + (activity as? MainActivity)?.binding?.toolbar?.setTitleTextColor( + Color.argb( + calculatedAlpha, + toolbarTextColor.red, + toolbarTextColor.green, + toolbarTextColor.blue + ) + ) + } + + private fun updateFilterIconState() { + episodesHeaderAdapter?.setHasActiveFilters(settingsSheet?.filters?.hasActiveFilters() == true) + } + + override fun configureFab(fab: ExtendedFloatingActionButton) { + actionFab = fab + fab.setText(R.string.action_start) + fab.setIconResource(R.drawable.ic_play_arrow_24dp) + fab.setOnClickListener { + val item = presenter.getNextUnreadEpisode() + if (item != null) { + // Create animation listener + val revealAnimationListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator?) { + openEpisode(item.episode, true) + } + } + + // Get coordinates and start animation + actionFab?.getCoordinates()?.let { coordinates -> + if (!binding.revealView.showRevealEffect( + coordinates.x, + coordinates.y, + revealAnimationListener + ) + ) { + openEpisode(item.episode) + } + } + } else { + view?.context?.toast(R.string.no_next_chapter) + } + } + } + + override fun cleanupFab(fab: ExtendedFloatingActionButton) { + fab.setOnClickListener(null) + actionFabScrollListener?.let { binding.recycler.removeOnScrollListener(it) } + actionFab = null + } + + override fun onDestroyView(view: View) { + destroyActionModeIfNeeded() + (activity as? MainActivity)?.clearFixViewToBottom(binding.actionToolbar) + binding.actionToolbar.destroy() + animeInfoAdapter = null + episodesHeaderAdapter = null + episodesAdapter = null + settingsSheet = null + addSnackbar?.dismiss() + updateToolbarTitleAlpha(255) + super.onDestroyView(view) + } + + override fun onActivityResumed(activity: Activity) { + if (view == null) return + + // Check if animation view is visible + if (binding.revealView.isVisible) { + // Show the unreveal effect + actionFab?.getCoordinates()?.let { coordinates -> + binding.revealView.hideRevealEffect(coordinates.x, coordinates.y, 1920) + } + } + + super.onActivityResumed(activity) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.anime, menu) + } + + override fun onPrepareOptionsMenu(menu: Menu) { + // Hide options for local anime + menu.findItem(R.id.action_share).isVisible = !isLocalSource + menu.findItem(R.id.download_group).isVisible = !isLocalSource + + // Hide options for non-animelib anime + menu.findItem(R.id.action_edit_categories).isVisible = presenter.anime.favorite && presenter.getCategories().isNotEmpty() + menu.findItem(R.id.action_edit_cover).isVisible = presenter.anime.favorite + menu.findItem(R.id.action_migrate).isVisible = presenter.anime.favorite + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_share -> shareAnime() + R.id.download_next, R.id.download_next_5, R.id.download_next_10, + R.id.download_custom, R.id.download_unread, R.id.download_all + -> downloadEpisodes(item.itemId) + + R.id.action_edit_categories -> onCategoriesClick() + R.id.action_edit_cover -> handleChangeCover() + R.id.action_migrate -> migrateAnime() + } + return super.onOptionsItemSelected(item) + } + + private fun updateRefreshing() { + binding.swipeRefresh.isRefreshing = isRefreshingInfo || isRefreshingEpisodes + } + + // Anime info - start + + /** + * Check if anime is initialized. + * If true update header with anime information, + * if false fetch anime information + * + * @param anime anime object containing information about anime. + * @param source the source of the anime. + */ + fun onNextAnimeInfo(anime: Anime, source: Source) { + if (anime.initialized) { + // Update view. + animeInfoAdapter?.update(anime, source) + } else { + // Initialize anime. + fetchAnimeInfoFromSource() + } + } + + /** + * Start fetching anime information from source. + */ + private fun fetchAnimeInfoFromSource(manualFetch: Boolean = false) { + isRefreshingInfo = true + updateRefreshing() + + // Call presenter and start fetching anime information + presenter.fetchAnimeFromSource(manualFetch) + } + + fun onFetchAnimeInfoDone() { + isRefreshingInfo = false + updateRefreshing() + } + + fun onFetchAnimeInfoError(error: Throwable) { + isRefreshingInfo = false + updateRefreshing() + activity?.toast(error.message) + } + + fun onTrackingCount(trackCount: Int) { + animeInfoAdapter?.setTrackingCount(trackCount) + } + + fun openAnimeInWebView() { + val source = presenter.source as? AnimeHttpSource ?: return + + val url = try { + source.animeDetailsRequest(presenter.anime).url.toString() + } catch (e: Exception) { + return + } + + val activity = activity ?: return + val intent = WebViewActivity.newIntent(activity, url, source.id, presenter.anime.title) + startActivity(intent) + } + + fun shareAnime() { + val context = view?.context ?: return + + val source = presenter.source as? AnimeHttpSource ?: return + try { + val url = source.animeDetailsRequest(presenter.anime).url.toString() + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + } + startActivity(Intent.createChooser(intent, context.getString(R.string.action_share))) + } catch (e: Exception) { + context.toast(e.message) + } + } + + fun onFavoriteClick() { + val anime = presenter.anime + + if (anime.favorite) { + toggleFavorite() + activity?.toast(activity?.getString(R.string.manga_removed_library)) + activity?.invalidateOptionsMenu() + } else { + addToAnimelib(anime) + } + } + + fun onTrackingClick() { + trackSheet?.show() + } + + private fun addToAnimelib(anime: Anime) { + val categories = presenter.getCategories() + val defaultCategoryId = preferences.defaultCategory() + val defaultCategory = categories.find { it.id == defaultCategoryId } + + when { + // Default category set + defaultCategory != null -> { + toggleFavorite() + presenter.moveAnimeToCategory(anime, defaultCategory) + activity?.toast(activity?.getString(R.string.manga_added_library)) + activity?.invalidateOptionsMenu() + } + + // Automatic 'Default' or no categories + defaultCategoryId == 0 || categories.isEmpty() -> { + toggleFavorite() + presenter.moveAnimeToCategory(anime, null) + activity?.toast(activity?.getString(R.string.manga_added_library)) + activity?.invalidateOptionsMenu() + } + + // Choose a category + else -> { + val ids = presenter.getAnimeCategoryIds(anime) + val preselected = ids.mapNotNull { id -> + categories.indexOfFirst { it.id == id }.takeIf { it != -1 } + }.toTypedArray() + + ChangeAnimeCategoriesDialog(this, listOf(anime), categories, preselected) + .showDialog(router) + } + } + } + + /** + * Toggles the favorite status and asks for confirmation to delete downloaded episodes. + */ + private fun toggleFavorite() { + val isNowFavorite = presenter.toggleFavorite() + if (activity != null && !isNowFavorite && presenter.hasDownloads()) { + (activity as? MainActivity)?.binding?.rootCoordinator?.snack(activity!!.getString(R.string.delete_downloads_for_manga)) { + setAction(R.string.action_delete) { + presenter.deleteDownloads() + } + } + } + + animeInfoAdapter?.notifyDataSetChanged() + } + + fun onCategoriesClick() { + val anime = presenter.anime + val categories = presenter.getCategories() + + val ids = presenter.getAnimeCategoryIds(anime) + val preselected = ids.mapNotNull { id -> + categories.indexOfFirst { it.id == id }.takeIf { it != -1 } + }.toTypedArray() + + ChangeAnimeCategoriesDialog(this, listOf(anime), categories, preselected) + .showDialog(router) + } + + override fun updateCategoriesForAnimes(animes: List, categories: List) { + val anime = animes.firstOrNull() ?: return + + if (!anime.favorite) { + toggleFavorite() + activity?.toast(activity?.getString(R.string.manga_added_library)) + activity?.invalidateOptionsMenu() + } + + presenter.moveAnimeToCategories(anime, categories) + } + + /** + * Perform a global search using the provided query. + * + * @param query the search query to pass to the search controller + */ + fun performGlobalSearch(query: String) { + router.pushController(GlobalSearchController(query).withFadeTransaction()) + } + + /** + * Perform a search using the provided query. + * + * @param query the search query to the parent controller + */ + fun performSearch(query: String) { + if (router.backstackSize < 2) { + return + } + + when (val previousController = router.backstack[router.backstackSize - 2].controller()) { + is AnimelibController -> { + router.handleBack() + previousController.search(query) + } + is UpdatesController, + is HistoryController -> { + // Manually navigate to AnimelibController + router.handleBack() + (router.activity as MainActivity).setSelectedNavItem(R.id.nav_library) + val controller = router.getControllerWithTag(R.id.nav_library.toString()) as AnimelibController + controller.search(query) + } + is LatestUpdatesController -> { + // Search doesn't currently work in source Latest view + return + } + is BrowseSourceController -> { + router.handleBack() + previousController.searchWithQuery(query) + } + } + } + + private fun handleChangeCover() { + val anime = anime ?: return + if (anime.hasCustomCover(coverCache)) { + showEditCoverDialog(anime) + } else { + openAnimeCoverPicker(anime) + } + } + + /** + * Edit custom cover for selected anime. + */ + private fun showEditCoverDialog(anime: Anime) { + ChangeAnimeCoverDialog(this, anime).showDialog(router) + } + + override fun openAnimeCoverPicker(anime: Anime) { + if (anime.favorite) { + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { + type = "image/*" + } + startActivityForResult( + Intent.createChooser( + intent, + resources?.getString(R.string.file_select_cover) + ), + REQUEST_IMAGE_OPEN + ) + } else { + activity?.toast(R.string.notification_first_add_to_library) + } + + destroyActionModeIfNeeded() + } + + override fun deleteAnimeCover(anime: Anime) { + presenter.deleteCustomCover(anime) + animeInfoAdapter?.notifyDataSetChanged() + destroyActionModeIfNeeded() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_IMAGE_OPEN) { + val dataUri = data?.data + if (dataUri == null || resultCode != Activity.RESULT_OK) return + val activity = activity ?: return + presenter.editCover(anime!!, activity, dataUri) + } + } + + fun onSetCoverSuccess() { + animeInfoAdapter?.notifyDataSetChanged() + activity?.toast(R.string.cover_updated) + } + + fun onSetCoverError(error: Throwable) { + activity?.toast(R.string.notification_cover_update_failed) + Timber.e(error) + } + + /** + * Initiates source migration for the specific anime. + */ + private fun migrateAnime() { + val controller = AnimeSearchController(presenter.anime) + controller.targetController = this + router.pushController(controller.withFadeTransaction()) + } + + // Anime info - end + + // Episodes list - start + + fun onNextEpisodes(episodes: List) { + // If the list is empty and it hasn't requested previously, fetch episodes from source + // We use presenter episodes instead because they are always unfiltered + if (!presenter.hasRequested && presenter.episodes.isEmpty()) { + fetchEpisodesFromSource() + } + + val episodesHeader = episodesHeaderAdapter ?: return + episodesHeader.setNumEpisodes(episodes.size) + + val adapter = episodesAdapter ?: return + adapter.updateDataSet(episodes) + + if (selectedEpisodes.isNotEmpty()) { + adapter.clearSelection() // we need to start from a clean state, index may have changed + createActionModeIfNeeded() + selectedEpisodes.forEach { item -> + val position = adapter.indexOf(item) + if (position != -1 && !adapter.isSelected(position)) { + adapter.toggleSelection(position) + } + } + actionMode?.invalidate() + } + + val context = view?.context + if (context != null && episodes.any { it.read }) { + actionFab?.text = context.getString(R.string.action_resume) + } + } + + private fun fetchEpisodesFromSource(manualFetch: Boolean = false) { + isRefreshingEpisodes = true + updateRefreshing() + + presenter.fetchEpisodesFromSource(manualFetch) + } + + fun onFetchEpisodesDone() { + isRefreshingEpisodes = false + updateRefreshing() + } + + fun onFetchEpisodesError(error: Throwable) { + isRefreshingEpisodes = false + updateRefreshing() + if (error is NoEpisodesException) { + activity?.toast(activity?.getString(R.string.no_episodes_error)) + } else { + activity?.toast(error.message) + } + } + + fun onEpisodeDownloadUpdate(download: AnimeDownload) { + episodesAdapter?.currentItems?.find { it.id == download.episode.id }?.let { + episodesAdapter?.updateItem(it) + episodesAdapter?.notifyDataSetChanged() + } + } + + fun openEpisode(episode: Episode, hasAnimation: Boolean = false) { + val activity = activity ?: return + val intent = WatcherActivity.newIntent(activity, presenter.anime, episode) + if (hasAnimation) { + intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + } + startActivity(intent) + } + + override fun onItemClick(view: View?, position: Int): Boolean { + val adapter = episodesAdapter ?: return false + val item = adapter.getItem(position) ?: return false + return if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { + if (adapter.isSelected(position)) { + lastClickPositionStack.remove(position) // possible that it's not there, but no harm + } else { + lastClickPositionStack.push(position) + } + + toggleSelection(position) + true + } else { + openEpisode(item.episode) + false + } + } + + override fun onItemLongClick(position: Int) { + createActionModeIfNeeded() + val lastClickPosition = lastClickPositionStack.peek()!! + when { + lastClickPosition == -1 -> setSelection(position) + lastClickPosition > position -> + for (i in position until lastClickPosition) + setSelection(i) + lastClickPosition < position -> + for (i in lastClickPosition + 1..position) + setSelection(i) + else -> setSelection(position) + } + if (lastClickPosition != position) { + lastClickPositionStack.remove(position) // move to top if already exists + lastClickPositionStack.push(position) + } + episodesAdapter?.notifyDataSetChanged() + } + + fun showSettingsSheet() { + settingsSheet?.show() + } + + // SELECTIONS & ACTION MODE + + private fun toggleSelection(position: Int) { + val adapter = episodesAdapter ?: return + val item = adapter.getItem(position) ?: return + adapter.toggleSelection(position) + adapter.notifyDataSetChanged() + if (adapter.isSelected(position)) { + selectedEpisodes.add(item) + } else { + selectedEpisodes.remove(item) + } + actionMode?.invalidate() + } + + private fun setSelection(position: Int) { + val adapter = episodesAdapter ?: return + val item = adapter.getItem(position) ?: return + if (!adapter.isSelected(position)) { + adapter.toggleSelection(position) + selectedEpisodes.add(item) + actionMode?.invalidate() + } + } + + private fun getSelectedEpisodes(): List { + val adapter = episodesAdapter ?: return emptyList() + return adapter.selectedPositions.mapNotNull { adapter.getItem(it) } + } + + private fun createActionModeIfNeeded() { + if (actionMode == null) { + actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this) + binding.actionToolbar.show( + actionMode!!, + R.menu.episode_selection + ) { onActionItemClicked(it!!) } + } + } + + private fun destroyActionModeIfNeeded() { + lastClickPositionStack.clear() + lastClickPositionStack.push(-1) + actionMode?.finish() + } + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.generic_selection, menu) + episodesAdapter?.mode = SelectableAdapter.Mode.MULTI + return true + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + val count = episodesAdapter?.selectedItemCount ?: 0 + if (count == 0) { + // Destroy action mode if there are no items selected. + destroyActionModeIfNeeded() + } else { + mode.title = count.toString() + + val episodes = getSelectedEpisodes() + binding.actionToolbar.findItem(R.id.action_download)?.isVisible = !isLocalSource && episodes.any { !it.isDownloaded } + binding.actionToolbar.findItem(R.id.action_delete)?.isVisible = !isLocalSource && episodes.any { it.isDownloaded } + binding.actionToolbar.findItem(R.id.action_bookmark)?.isVisible = episodes.any { !it.episode.bookmark } + binding.actionToolbar.findItem(R.id.action_remove_bookmark)?.isVisible = episodes.all { it.episode.bookmark } + binding.actionToolbar.findItem(R.id.action_mark_as_read)?.isVisible = episodes.any { !it.episode.read } + binding.actionToolbar.findItem(R.id.action_mark_as_unread)?.isVisible = episodes.all { it.episode.read } + + // Hide FAB to avoid interfering with the bottom action toolbar + actionFab?.isVisible = false + } + return false + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return onActionItemClicked(item) + } + + private fun onActionItemClicked(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_select_all -> selectAll() + R.id.action_select_inverse -> selectInverse() + R.id.action_download -> downloadEpisodes(getSelectedEpisodes()) + R.id.action_delete -> showDeleteEpisodesConfirmationDialog() + R.id.action_bookmark -> bookmarkEpisodes(getSelectedEpisodes(), true) + R.id.action_remove_bookmark -> bookmarkEpisodes(getSelectedEpisodes(), false) + R.id.action_mark_as_read -> markAsRead(getSelectedEpisodes()) + R.id.action_mark_as_unread -> markAsUnread(getSelectedEpisodes()) + R.id.action_mark_previous_as_read -> markPreviousAsRead(getSelectedEpisodes()) + else -> return false + } + return true + } + + override fun onDestroyActionMode(mode: ActionMode) { + binding.actionToolbar.hide() + episodesAdapter?.mode = SelectableAdapter.Mode.SINGLE + episodesAdapter?.clearSelection() + selectedEpisodes.clear() + actionMode = null + actionFab?.isVisible = true + } + + override fun onDetach(view: View) { + destroyActionModeIfNeeded() + super.onDetach(view) + } + + override fun downloadEpisode(position: Int) { + val item = episodesAdapter?.getItem(position) ?: return + if (item.status == AnimeDownload.State.ERROR) { + DownloadService.start(activity!!) + } else { + downloadEpisodes(listOf(item)) + } + episodesAdapter?.updateItem(item) + } + + override fun deleteEpisode(position: Int) { + val item = episodesAdapter?.getItem(position) ?: return + deleteEpisodes(listOf(item)) + episodesAdapter?.updateItem(item) + } + + // SELECTION MODE ACTIONS + + private fun selectAll() { + val adapter = episodesAdapter ?: return + adapter.selectAll() + selectedEpisodes.addAll(adapter.items) + actionMode?.invalidate() + } + + private fun selectInverse() { + val adapter = episodesAdapter ?: return + + selectedEpisodes.clear() + for (i in 0..adapter.itemCount) { + adapter.toggleSelection(i) + } + selectedEpisodes.addAll(adapter.selectedPositions.mapNotNull { adapter.getItem(it) }) + + actionMode?.invalidate() + adapter.notifyDataSetChanged() + } + + private fun markAsRead(episodes: List) { + presenter.markEpisodesRead(episodes, true) + destroyActionModeIfNeeded() + } + + private fun markAsUnread(episodes: List) { + presenter.markEpisodesRead(episodes, false) + destroyActionModeIfNeeded() + } + + private fun downloadEpisodes(episodes: List) { + if (source is SourceManager.StubSource) { + activity?.toast(R.string.loader_not_implemented_error) + return + } + + val view = view + val anime = presenter.anime + presenter.downloadEpisodes(episodes) + if (view != null && !anime.favorite) { + addSnackbar = (activity as? MainActivity)?.binding?.rootCoordinator?.snack(view.context.getString(R.string.snack_add_to_animelib), Snackbar.LENGTH_INDEFINITE) { + setAction(R.string.action_add) { + addToAnimelib(anime) + } + } + } + destroyActionModeIfNeeded() + } + + private fun showDeleteEpisodesConfirmationDialog() { + DeleteEpisodesDialog(this).showDialog(router) + } + + override fun deleteEpisodes() { + deleteEpisodes(getSelectedEpisodes()) + } + + private fun markPreviousAsRead(episodes: List) { + val adapter = episodesAdapter ?: return + val prevEpisodes = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items + val episodePos = prevEpisodes.indexOf(episodes.lastOrNull()) + if (episodePos != -1) { + markAsRead(prevEpisodes.take(episodePos)) + } + destroyActionModeIfNeeded() + } + + private fun bookmarkEpisodes(episodes: List, bookmarked: Boolean) { + presenter.bookmarkEpisodes(episodes, bookmarked) + destroyActionModeIfNeeded() + } + + fun deleteEpisodes(episodes: List) { + if (episodes.isEmpty()) return + + presenter.deleteEpisodes(episodes) + destroyActionModeIfNeeded() + } + + fun onEpisodesDeleted(episodes: List) { + // this is needed so the downloaded text gets removed from the item + episodes.forEach { + episodesAdapter?.updateItem(it) + } + launchUI { + episodesAdapter?.notifyDataSetChanged() + } + } + + fun onEpisodesDeletedError(error: Throwable) { + Timber.e(error) + } + + // OVERFLOW MENU DIALOGS + + private fun getUnreadEpisodesSorted() = presenter.episodes + .sortedWith(presenter.getEpisodeSort()) + .filter { !it.read && it.status == AnimeDownload.State.NOT_DOWNLOADED } + .distinctBy { it.name } + .reversed() + + private fun downloadEpisodes(choice: Int) { + val episodesToDownload = when (choice) { + R.id.download_next -> getUnreadEpisodesSorted().take(1) + R.id.download_next_5 -> getUnreadEpisodesSorted().take(5) + R.id.download_next_10 -> getUnreadEpisodesSorted().take(10) + R.id.download_custom -> { + showCustomDownloadDialog() + return + } + R.id.download_unread -> presenter.episodes.filter { !it.read } + R.id.download_all -> presenter.episodes + else -> emptyList() + } + if (episodesToDownload.isNotEmpty()) { + downloadEpisodes(episodesToDownload) + } + destroyActionModeIfNeeded() + } + + private fun showCustomDownloadDialog() { + DownloadCustomEpisodesDialog( + this, + presenter.episodes.size + ).showDialog(router) + } + + override fun downloadCustomEpisodes(amount: Int) { + val episodesToDownload = getUnreadEpisodesSorted().take(amount) + if (episodesToDownload.isNotEmpty()) { + downloadEpisodes(episodesToDownload) + } + } + + // Episodes list - end + + // Tracker sheet - start + fun onNextTrackers(trackers: List) { + trackSheet?.onNextTrackers(trackers) + } + + fun onTrackingRefreshDone() { + } + + fun onTrackingRefreshError(error: Throwable) { + Timber.e(error) + activity?.toast(error.message) + } + + fun onTrackingSearchResults(results: List) { + getTrackingSearchDialog()?.onSearchResults(results) + } + + fun onTrackingSearchResultsError(error: Throwable) { + Timber.e(error) + activity?.toast(error.message) + getTrackingSearchDialog()?.onSearchResultsError() + } + + private fun getTrackingSearchDialog(): TrackSearchDialog? { + return trackSheet?.getSearchDialog() + } + + // Tracker sheet - end + + companion object { + const val FROM_SOURCE_EXTRA = "from_source" + const val MANGA_EXTRA = "anime" + + /** + * Key to change the cover of a anime in [onActivityResult]. + */ + const val REQUEST_IMAGE_OPEN = 101 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimePresenter.kt new file mode 100644 index 000000000..d8ec583e3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimePresenter.kt @@ -0,0 +1,797 @@ +package eu.kanade.tachiyomi.ui.anime + +import android.content.Context +import android.net.Uri +import android.os.Bundle +import com.jakewharton.rxrelay.PublishRelay +import eu.kanade.tachiyomi.data.cache.AnimeCoverCache +import eu.kanade.tachiyomi.data.database.AnimeDatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Episode +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.data.database.models.AnimeCategory +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.AnimeTrack +import eu.kanade.tachiyomi.data.database.models.toAnimeInfo +import eu.kanade.tachiyomi.data.download.AnimeDownloadManager +import eu.kanade.tachiyomi.data.download.model.AnimeDownload +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.model.toSEpisode +import eu.kanade.tachiyomi.source.model.toSAnime +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.ui.anime.episode.EpisodeItem +import eu.kanade.tachiyomi.ui.anime.track.TrackItem +import eu.kanade.tachiyomi.util.* +import eu.kanade.tachiyomi.util.episode.EpisodeSettingsHelper +import eu.kanade.tachiyomi.util.episode.syncEpisodesWithSource +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.lang.withUIContext +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.supervisorScope +import rx.Observable +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.Date + +class AnimePresenter( + val anime: Anime, + val source: Source, + val preferences: PreferencesHelper = Injekt.get(), + private val db: AnimeDatabaseHelper = Injekt.get(), + private val trackManager: TrackManager = Injekt.get(), + private val downloadManager: AnimeDownloadManager = Injekt.get(), + private val coverCache: AnimeCoverCache = Injekt.get() +) : BasePresenter() { + + /** + * Subscription to update the anime from the source. + */ + private var fetchAnimeJob: Job? = null + + /** + * List of episodes of the anime. It's always unfiltered and unsorted. + */ + var episodes: List = emptyList() + private set + + /** + * Subject of list of episodes to allow updating the view without going to DB. + */ + private val episodesRelay: PublishRelay> by lazy { + PublishRelay.create>() + } + + /** + * Whether the episode list has been requested to the source. + */ + var hasRequested = false + private set + + /** + * Subscription to retrieve the new list of episodes from the source. + */ + private var fetchEpisodesJob: Job? = null + + /** + * Subscription to observe download status changes. + */ + private var observeDownloadsStatusSubscription: Subscription? = null + private var observeDownloadsPageSubscription: Subscription? = null + + private var _trackList: List = emptyList() + val trackList get() = _trackList + + private val loggedServices by lazy { trackManager.services.filter { it.isLogged } } + + private var trackSubscription: Subscription? = null + private var searchTrackerJob: Job? = null + private var refreshTrackersJob: Job? = null + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + + if (!anime.favorite) { + EpisodeSettingsHelper.applySettingDefaults(anime) + } + + // Anime info - start + + getAnimeObservable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache({ view, anime -> view.onNextAnimeInfo(anime, source) }) + + getTrackingObservable() + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache(AnimeController::onTrackingCount) { _, error -> Timber.e(error) } + + // Prepare the relay. + episodesRelay.flatMap { applyEpisodeFilters(it) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache(AnimeController::onNextEpisodes) { _, error -> Timber.e(error) } + + // Anime info - end + + // Episodes list - start + + // Add the subscription that retrieves the episodes from the database, keeps subscribed to + // changes, and sends the list of episodes to the relay. + add( + db.getEpisodes(anime).asRxObservable() + .map { episodes -> + // Convert every episode to a model. + episodes.map { it.toModel() } + } + .doOnNext { episodes -> + // Find downloaded episodes + setDownloadedEpisodes(episodes) + + // Store the last emission + this.episodes = episodes + + // Listen for download status changes + observeDownloads() + } + .subscribe { episodesRelay.call(it) } + ) + + // Episodes list - end + + fetchTrackers() + } + + // Anime info - start + + private fun getAnimeObservable(): Observable { + return db.getAnime(anime.url, anime.source).asRxObservable() + } + + private fun getTrackingObservable(): Observable { + if (!trackManager.hasLoggedServices()) { + return Observable.just(0) + } + + return db.getTracks(anime).asRxObservable() + .map { tracks -> + val loggedServices = trackManager.services.filter { it.isLogged }.map { it.id } + tracks.filter { it.sync_id in loggedServices } + } + .map { it.size } + } + + /** + * Fetch anime information from source. + */ + fun fetchAnimeFromSource(manualFetch: Boolean = false) { + if (fetchAnimeJob?.isActive == true) return + fetchAnimeJob = presenterScope.launchIO { + try { + val networkAnime = source.getAnimeDetails(anime.toAnimeInfo()) + val sAnime = networkAnime.toSAnime() + anime.prepUpdateCover(coverCache, sAnime, manualFetch) + anime.copyFrom(sAnime) + anime.initialized = true + db.insertAnime(anime).executeAsBlocking() + + withUIContext { view?.onFetchAnimeInfoDone() } + } catch (e: Throwable) { + withUIContext { view?.onFetchAnimeInfoError(e) } + } + } + } + + /** + * Update favorite status of anime, (removes / adds) anime (to / from) library. + * + * @return the new status of the anime. + */ + fun toggleFavorite(): Boolean { + anime.favorite = !anime.favorite + anime.date_added = when (anime.favorite) { + true -> Date().time + false -> 0 + } + if (!anime.favorite) { + anime.removeCovers(coverCache) + } + db.insertAnime(anime).executeAsBlocking() + return anime.favorite + } + + /** + * Returns true if the anime has any downloads. + */ + fun hasDownloads(): Boolean { + return downloadManager.getDownloadCount(anime) > 0 + } + + /** + * Deletes all the downloads for the anime. + */ + fun deleteDownloads() { + downloadManager.deleteAnime(anime, source) + } + + /** + * Get user categories. + * + * @return List of categories, not including the default category + */ + fun getCategories(): List { + return db.getCategories().executeAsBlocking() + } + + /** + * Gets the category id's the anime is in, if the anime is not in a category, returns the default id. + * + * @param anime the anime to get categories from. + * @return Array of category ids the anime is in, if none returns default id + */ + fun getAnimeCategoryIds(anime: Anime): Array { + val categories = db.getCategoriesForAnime(anime).executeAsBlocking() + return categories.mapNotNull { it.id }.toTypedArray() + } + + /** + * Move the given anime to categories. + * + * @param anime the anime to move. + * @param categories the selected categories. + */ + fun moveAnimeToCategories(anime: Anime, categories: List) { + val ac = categories.filter { it.id != 0 }.map { AnimeCategory.create(anime, it) } + db.setAnimeCategories(ac, listOf(anime)) + } + + /** + * Move the given anime to the category. + * + * @param anime the anime to move. + * @param category the selected category, or null for default category. + */ + fun moveAnimeToCategory(anime: Anime, category: Category?) { + moveAnimeToCategories(anime, listOfNotNull(category)) + } + + /** + * Update cover with local file. + * + * @param anime the anime edited. + * @param context Context. + * @param data uri of the cover resource. + */ + fun editCover(anime: Anime, context: Context, data: Uri) { + Observable + .fromCallable { + context.contentResolver.openInputStream(data)?.use { + if (anime.isLocal()) { + LocalSource.updateCover(context, anime, it) + anime.updateCoverLastModified(db) + } else if (anime.favorite) { + coverCache.setCustomCoverToCache(anime, it) + anime.updateCoverLastModified(db) + } + } + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, _ -> view.onSetCoverSuccess() }, + { view, e -> view.onSetCoverError(e) } + ) + } + + fun deleteCustomCover(anime: Anime) { + Observable + .fromCallable { + coverCache.deleteCustomCover(anime) + anime.updateCoverLastModified(db) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, _ -> view.onSetCoverSuccess() }, + { view, e -> view.onSetCoverError(e) } + ) + } + + // Anime info - end + + // Episodes list - start + + private fun observeDownloads() { + observeDownloadsStatusSubscription?.let { remove(it) } + observeDownloadsStatusSubscription = downloadManager.queue.getStatusObservable() + .observeOn(Schedulers.io()) + .onBackpressureLatest() + .filter { download -> download.anime.id == anime.id } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache( + { view, it -> + onDownloadStatusChange(it) + view.onEpisodeDownloadUpdate(it) + }, + { _, error -> + Timber.e(error) + } + ) + + observeDownloadsPageSubscription?.let { remove(it) } + observeDownloadsPageSubscription = downloadManager.queue.getProgressObservable() + .observeOn(Schedulers.io()) + .onBackpressureLatest() + .filter { download -> download.anime.id == anime.id } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestAnimeCache(AnimeController::onEpisodeDownloadUpdate) { _, error -> + Timber.e(error) + } + } + + /** + * Converts a episode from the database to an extended model, allowing to store new fields. + */ + private fun Episode.toModel(): EpisodeItem { + // Create the model object. + val model = EpisodeItem(this, anime) + + // Find an active download for this episode. + val download = downloadManager.queue.find { it.episode.id == id } + + if (download != null) { + // If there's an active download, assign it. + model.download = download + } + return model + } + + /** + * Finds and assigns the list of downloaded episodes. + * + * @param episodes the list of episode from the database. + */ + private fun setDownloadedEpisodes(episodes: List) { + episodes + .filter { downloadManager.isEpisodeDownloaded(it, anime) } + .forEach { it.status = AnimeDownload.State.DOWNLOADED } + } + + /** + * Requests an updated list of episodes from the source. + */ + fun fetchEpisodesFromSource(manualFetch: Boolean = false) { + hasRequested = true + + if (fetchEpisodesJob?.isActive == true) return + fetchEpisodesJob = presenterScope.launchIO { + try { + val episodes = source.getEpisodeList(anime.toAnimeInfo()) + .map { it.toSEpisode() } + + val (newEpisodes, _) = syncEpisodesWithSource(db, episodes, anime, source) + if (manualFetch) { + downloadNewEpisodes(newEpisodes) + } + + withUIContext { view?.onFetchEpisodesDone() } + } catch (e: Throwable) { + withUIContext { view?.onFetchEpisodesError(e) } + } + } + } + + /** + * Updates the UI after applying the filters. + */ + private fun refreshEpisodes() { + episodesRelay.call(episodes) + } + + /** + * Applies the view filters to the list of episodes obtained from the database. + * @param episodes the list of episodes from the database + * @return an observable of the list of episodes filtered and sorted. + */ + private fun applyEpisodeFilters(episodes: List): Observable> { + var observable = Observable.from(episodes).subscribeOn(Schedulers.io()) + + val unreadFilter = onlyUnread() + if (unreadFilter == State.INCLUDE) { + observable = observable.filter { !it.read } + } else if (unreadFilter == State.EXCLUDE) { + observable = observable.filter { it.read } + } + + val downloadedFilter = onlyDownloaded() + if (downloadedFilter == State.INCLUDE) { + observable = observable.filter { it.isDownloaded || it.anime.isLocal() } + } else if (downloadedFilter == State.EXCLUDE) { + observable = observable.filter { !it.isDownloaded && !it.anime.isLocal() } + } + + val bookmarkedFilter = onlyBookmarked() + if (bookmarkedFilter == State.INCLUDE) { + observable = observable.filter { it.bookmark } + } else if (bookmarkedFilter == State.EXCLUDE) { + observable = observable.filter { !it.bookmark } + } + + return observable.toSortedList(getEpisodeSort()) + } + + fun getEpisodeSort(): (Episode, Episode) -> Int { + return when (anime.sorting) { + Anime.SORTING_SOURCE -> when (sortDescending()) { + true -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) } + false -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) } + } + Anime.SORTING_NUMBER -> when (sortDescending()) { + true -> { c1, c2 -> c2.episode_number.compareTo(c1.episode_number) } + false -> { c1, c2 -> c1.episode_number.compareTo(c2.episode_number) } + } + Anime.SORTING_UPLOAD_DATE -> when (sortDescending()) { + true -> { c1, c2 -> c2.date_upload.compareTo(c1.date_upload) } + false -> { c1, c2 -> c1.date_upload.compareTo(c2.date_upload) } + } + else -> throw NotImplementedError("Unimplemented sorting method") + } + } + + /** + * Called when a download for the active anime changes status. + * @param download the download whose status changed. + */ + private fun onDownloadStatusChange(download: AnimeDownload) { + // Assign the download to the model object. + if (download.status == AnimeDownload.State.QUEUE) { + episodes.find { it.id == download.episode.id }?.let { + if (it.download == null) { + it.download = download + } + } + } + + // Force UI update if downloaded filter active and download finished. + if (onlyDownloaded() != State.IGNORE && download.status == AnimeDownload.State.DOWNLOADED) { + refreshEpisodes() + } + } + + /** + * Returns the next unread episode or null if everything is read. + */ + fun getNextUnreadEpisode(): EpisodeItem? { + return episodes.sortedWith(getEpisodeSort()).findLast { !it.read } + } + + /** + * Mark the selected episode list as read/unread. + * @param selectedEpisodes the list of selected episodes. + * @param read whether to mark episodes as read or unread. + */ + fun markEpisodesRead(selectedEpisodes: List, read: Boolean) { + val episodes = selectedEpisodes.map { episode -> + episode.read = read + if (!read) { + episode.last_page_read = 0 + } + episode + } + + launchIO { + db.updateEpisodesProgress(episodes).executeAsBlocking() + + if (preferences.removeAfterMarkedAsRead()) { + deleteEpisodes(episodes.filter { it.read }) + } + } + } + + /** + * Downloads the given list of episodes with the manager. + * @param episodes the list of episodes to download. + */ + fun downloadEpisodes(episodes: List) { + downloadManager.downloadEpisodes(anime, episodes) + } + + /** + * Bookmarks the given list of episodes. + * @param selectedEpisodes the list of episodes to bookmark. + */ + fun bookmarkEpisodes(selectedEpisodes: List, bookmarked: Boolean) { + launchIO { + selectedEpisodes + .forEach { + it.bookmark = bookmarked + db.updateEpisodeProgress(it).executeAsBlocking() + } + } + } + + /** + * Deletes the given list of episode. + * @param episodes the list of episodes to delete. + */ + fun deleteEpisodes(episodes: List) { + launchIO { + try { + downloadManager.deleteEpisodes(episodes, anime, source).forEach { + if (it is EpisodeItem) { + it.status = AnimeDownload.State.NOT_DOWNLOADED + it.download = null + } + } + + if (onlyDownloaded() != State.IGNORE) { + refreshEpisodes() + } + + view?.onEpisodesDeleted(episodes) + } catch (e: Throwable) { + view?.onEpisodesDeletedError(e) + } + } + } + + private fun downloadNewEpisodes(episodes: List) { + if (episodes.isEmpty() || !anime.shouldDownloadNewChapters(db, preferences)) return + + downloadEpisodes(episodes) + } + + /** + * Reverses the sorting and requests an UI update. + */ + fun reverseSortOrder() { + anime.setEpisodeOrder(if (sortDescending()) Anime.SORT_ASC else Anime.SORT_DESC) + db.updateFlags(anime).executeAsBlocking() + refreshEpisodes() + } + + /** + * Sets the read filter and requests an UI update. + * @param state whether to display only unread episodes or all episodes. + */ + fun setUnreadFilter(state: State) { + anime.readFilter = when (state) { + State.IGNORE -> Anime.SHOW_ALL + State.INCLUDE -> Anime.SHOW_UNREAD + State.EXCLUDE -> Anime.SHOW_READ + } + db.updateFlags(anime).executeAsBlocking() + refreshEpisodes() + } + + /** + * Sets the download filter and requests an UI update. + * @param state whether to display only downloaded episodes or all episodes. + */ + fun setDownloadedFilter(state: State) { + anime.downloadedFilter = when (state) { + State.IGNORE -> Anime.SHOW_ALL + State.INCLUDE -> Anime.SHOW_DOWNLOADED + State.EXCLUDE -> Anime.SHOW_NOT_DOWNLOADED + } + db.updateFlags(anime).executeAsBlocking() + refreshEpisodes() + } + + /** + * Sets the bookmark filter and requests an UI update. + * @param state whether to display only bookmarked episodes or all episodes. + */ + fun setBookmarkedFilter(state: State) { + anime.bookmarkedFilter = when (state) { + State.IGNORE -> Anime.SHOW_ALL + State.INCLUDE -> Anime.SHOW_BOOKMARKED + State.EXCLUDE -> Anime.SHOW_NOT_BOOKMARKED + } + db.updateFlags(anime).executeAsBlocking() + refreshEpisodes() + } + + /** + * Sets the active display mode. + * @param mode the mode to set. + */ + fun setDisplayMode(mode: Int) { + anime.displayMode = mode + db.updateFlags(anime).executeAsBlocking() + } + + /** + * Sets the sorting method and requests an UI update. + * @param sort the sorting mode. + */ + fun setSorting(sort: Int) { + anime.sorting = sort + db.updateFlags(anime).executeAsBlocking() + refreshEpisodes() + } + + /** + * Whether downloaded only mode is enabled. + */ + fun forceDownloaded(): Boolean { + return anime.favorite && preferences.downloadedOnly().get() + } + + /** + * Whether the display only downloaded filter is enabled. + */ + fun onlyDownloaded(): State { + if (forceDownloaded()) { + return State.INCLUDE + } + return when (anime.downloadedFilter) { + Anime.SHOW_DOWNLOADED -> State.INCLUDE + Anime.SHOW_NOT_DOWNLOADED -> State.EXCLUDE + else -> State.IGNORE + } + } + + /** + * Whether the display only downloaded filter is enabled. + */ + fun onlyBookmarked(): State { + return when (anime.bookmarkedFilter) { + Anime.SHOW_BOOKMARKED -> State.INCLUDE + Anime.SHOW_NOT_BOOKMARKED -> State.EXCLUDE + else -> State.IGNORE + } + } + + /** + * Whether the display only unread filter is enabled. + */ + fun onlyUnread(): State { + return when (anime.readFilter) { + Anime.SHOW_UNREAD -> State.INCLUDE + Anime.SHOW_READ -> State.EXCLUDE + else -> State.IGNORE + } + } + + /** + * Whether the sorting method is descending or ascending. + */ + fun sortDescending(): Boolean { + return anime.sortDescending() + } + + // Episodes list - end + + // Track sheet - start + + private fun fetchTrackers() { + trackSubscription?.let { remove(it) } + trackSubscription = db.getTracks(anime) + .asRxObservable() + .map { tracks -> + loggedServices.map { service -> + TrackItem(tracks.find { it.sync_id == service.id }, service) + } + } + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { _trackList = it } + .subscribeLatestCache(AnimeController::onNextTrackers) + } + + fun refreshTrackers() { + refreshTrackersJob?.cancel() + refreshTrackersJob = launchIO { + supervisorScope { + try { + trackList + .filter { it.track != null } + .map { + async { + val track = it.service.refreshAnime(it.track!!) + db.insertTrack(track).executeAsBlocking() + } + } + .awaitAll() + + withUIContext { view?.onTrackingRefreshDone() } + } catch (e: Throwable) { + withUIContext { view?.onTrackingRefreshError(e) } + } + } + } + } + + fun trackingSearch(query: String, service: TrackService) { + searchTrackerJob?.cancel() + searchTrackerJob = launchIO { + try { + val results = service.search(query) + withUIContext { view?.onTrackingSearchResults(results) } + } catch (e: Throwable) { + withUIContext { view?.onTrackingSearchResultsError(e) } + } + } + } + + fun registerTracking(item: AnimeTrack?, service: TrackService) { + if (item != null) { + item.anime_id = anime.id!! + launchIO { + try { + service.bindAnime(item) + db.insertTrack(item).executeAsBlocking() + } catch (e: Throwable) { + withUIContext { view?.applicationContext?.toast(e.message) } + } + } + } else { + unregisterTracking(service) + } + } + + fun unregisterTracking(service: TrackService) { + db.deleteTrackForAnime(anime, service).executeAsBlocking() + } + + private fun updateRemote(track: AnimeTrack, service: TrackService) { + launchIO { + try { + service.updateAnime(track) + db.insertTrack(track).executeAsBlocking() + withUIContext { view?.onTrackingRefreshDone() } + } catch (e: Throwable) { + withUIContext { view?.onTrackingRefreshError(e) } + + // Restart on error to set old values + fetchTrackers() + } + } + } + + fun setTrackerStatus(item: TrackItem, index: Int) { + val track = item.track!! + track.status = item.service.getStatusList()[index] + if (track.status == item.service.getCompletionStatus() && track.total_episodes != 0) { + track.last_episode_seen = track.total_episodes + } + updateRemote(track, item.service) + } + + fun setTrackerScore(item: TrackItem, index: Int) { + val track = item.track!! + track.score = item.service.indexToScore(index) + updateRemote(track, item.service) + } + + fun setTrackerLastEpisodeRead(item: TrackItem, episodeNumber: Int) { + val track = item.track!! + track.last_episode_seen = episodeNumber + if (track.total_episodes != 0 && track.last_episode_seen == track.total_episodes) { + track.status = item.service.getCompletionStatus() + } + updateRemote(track, item.service) + } + + fun setTrackerStartDate(item: TrackItem, date: Long) { + val track = item.track!! + track.started_watching_date = date + updateRemote(track, item.service) + } + + fun setTrackerFinishDate(item: TrackItem, date: Long) { + val track = item.track!! + track.finished_watching_date = date + updateRemote(track, item.service) + } + + // Track sheet - end +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/AnimeEpisodesHeaderAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/AnimeEpisodesHeaderAdapter.kt new file mode 100644 index 000000000..e177cc3a3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/AnimeEpisodesHeaderAdapter.kt @@ -0,0 +1,70 @@ +package eu.kanade.tachiyomi.ui.anime.episode + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.graphics.drawable.DrawableCompat +import androidx.recyclerview.widget.RecyclerView +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.AnimeEpisodesHeaderBinding +import eu.kanade.tachiyomi.ui.anime.AnimeController +import eu.kanade.tachiyomi.util.system.getResourceColor +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.view.clicks + +class AnimeEpisodesHeaderAdapter( + private val controller: AnimeController +) : + RecyclerView.Adapter() { + + private var numEpisodes: Int? = null + private var hasActiveFilters: Boolean = false + + private lateinit var binding: AnimeEpisodesHeaderBinding + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { + binding = AnimeEpisodesHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return HeaderViewHolder(binding.root) + } + + override fun getItemCount(): Int = 1 + + override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) { + holder.bind() + } + + fun setNumEpisodes(numEpisodes: Int) { + this.numEpisodes = numEpisodes + + notifyDataSetChanged() + } + + fun setHasActiveFilters(hasActiveFilters: Boolean) { + this.hasActiveFilters = hasActiveFilters + + notifyDataSetChanged() + } + + inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) { + fun bind() { + binding.episodesLabel.text = if (numEpisodes == null) { + view.context.getString(R.string.chapters) + } else { + view.context.resources.getQuantityString(R.plurals.manga_num_chapters, numEpisodes!!, numEpisodes) + } + + val filterColor = if (hasActiveFilters) { + view.context.getResourceColor(R.attr.colorFilterActive) + } else { + view.context.getResourceColor(R.attr.colorOnBackground) + } + DrawableCompat.setTint(binding.btnEpisodesFilter.drawable, filterColor) + + merge(view.clicks(), binding.btnEpisodesFilter.clicks()) + .onEach { controller.showSettingsSheet() } + .launchIn(controller.viewScope) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/DeleteEpisodesDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/DeleteEpisodesDialog.kt new file mode 100644 index 000000000..6a0e1c67d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/DeleteEpisodesDialog.kt @@ -0,0 +1,29 @@ +package eu.kanade.tachiyomi.ui.anime.episode + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.controller.DialogController + +class DeleteEpisodesDialog(bundle: Bundle? = null) : DialogController(bundle) + where T : Controller, T : DeleteEpisodesDialog.Listener { + + constructor(target: T) : this() { + targetController = target + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + return MaterialDialog(activity!!) + .message(R.string.confirm_delete_chapters) + .positiveButton(android.R.string.ok) { + (targetController as? Listener)?.deleteEpisodes() + } + .negativeButton(android.R.string.cancel) + } + + interface Listener { + fun deleteEpisodes() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/DownloadCustomEpisodesDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/DownloadCustomEpisodesDialog.kt new file mode 100644 index 000000000..f579d904f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/DownloadCustomEpisodesDialog.kt @@ -0,0 +1,77 @@ +package eu.kanade.tachiyomi.ui.anime.episode + +import android.app.Dialog +import android.os.Bundle +import androidx.core.os.bundleOf +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.widget.DialogCustomDownloadView + +/** + * Dialog used to let user select amount of episodes to download. + */ +class DownloadCustomEpisodesDialog : DialogController + where T : Controller, T : DownloadCustomEpisodesDialog.Listener { + + /** + * Maximum number of episodes to download in download chooser. + */ + private val maxEpisodes: Int + + /** + * Initialize dialog. + * @param maxEpisodes maximal number of episodes that user can download. + */ + constructor(target: T, maxEpisodes: Int) : super( + // Add maximum number of episodes to download value to bundle. + bundleOf(KEY_ITEM_MAX to maxEpisodes) + ) { + targetController = target + this.maxEpisodes = maxEpisodes + } + + /** + * Restore dialog. + * @param bundle bundle containing data from state restore. + */ + @Suppress("unused") + constructor(bundle: Bundle) : super(bundle) { + // Get maximum episodes to download from bundle + val maxEpisodes = bundle.getInt(KEY_ITEM_MAX, 0) + this.maxEpisodes = maxEpisodes + } + + /** + * Called when dialog is being created. + */ + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val activity = activity!! + + // Initialize view that lets user select number of episodes to download. + val view = DialogCustomDownloadView(activity).apply { + setMinMax(0, maxEpisodes) + } + + // Build dialog. + // when positive dialog is pressed call custom listener. + return MaterialDialog(activity) + .title(R.string.custom_download) + .customView(view = view, scrollable = true) + .positiveButton(android.R.string.ok) { + (targetController as? Listener)?.downloadCustomEpisodes(view.amount) + } + .negativeButton(android.R.string.cancel) + } + + interface Listener { + fun downloadCustomEpisodes(amount: Int) + } + + private companion object { + // Key to retrieve max episodes from bundle on process death. + const val KEY_ITEM_MAX = "DownloadCustomEpisodesDialog.int.maxEpisodes" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeDownloadView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeDownloadView.kt new file mode 100644 index 000000000..6a6dc8fe9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeDownloadView.kt @@ -0,0 +1,70 @@ +package eu.kanade.tachiyomi.ui.anime.episode + +import android.animation.ObjectAnimator +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.FrameLayout +import androidx.core.view.isVisible +import eu.kanade.tachiyomi.data.download.model.AnimeDownload +import eu.kanade.tachiyomi.databinding.EpisodeDownloadViewBinding + +class EpisodeDownloadView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + FrameLayout(context, attrs) { + + private val binding: EpisodeDownloadViewBinding + + private var state = AnimeDownload.State.NOT_DOWNLOADED + private var progress = 0 + + private var downloadIconAnimator: ObjectAnimator? = null + private var isAnimating = false + + init { + binding = EpisodeDownloadViewBinding.inflate(LayoutInflater.from(context), this, false) + addView(binding.root) + } + + fun setState(state: AnimeDownload.State, progress: Int = 0) { + val isDirty = this.state.value != state.value || this.progress != progress + + this.state = state + this.progress = progress + + if (isDirty) { + updateLayout() + } + } + + private fun updateLayout() { + binding.downloadIconBorder.isVisible = state == AnimeDownload.State.NOT_DOWNLOADED + + binding.downloadIcon.isVisible = state == AnimeDownload.State.NOT_DOWNLOADED || state == AnimeDownload.State.DOWNLOADING + if (state == AnimeDownload.State.DOWNLOADING) { + if (!isAnimating) { + downloadIconAnimator = + ObjectAnimator.ofFloat(binding.downloadIcon, "alpha", 1f, 0f).apply { + duration = 1000 + repeatCount = ObjectAnimator.INFINITE + repeatMode = ObjectAnimator.REVERSE + } + downloadIconAnimator?.start() + isAnimating = true + } + } else { + downloadIconAnimator?.cancel() + binding.downloadIcon.alpha = 1f + isAnimating = false + } + + binding.downloadQueued.isVisible = state == AnimeDownload.State.QUEUE + + binding.downloadProgress.isVisible = state == AnimeDownload.State.DOWNLOADING || + (state == AnimeDownload.State.QUEUE && progress > 0) + binding.downloadProgress.progress = progress + + binding.downloadedIcon.isVisible = state == AnimeDownload.State.DOWNLOADED + + binding.errorIcon.isVisible = state == AnimeDownload.State.ERROR + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeHolder.kt new file mode 100644 index 000000000..358d5eeaf --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeHolder.kt @@ -0,0 +1,82 @@ +package eu.kanade.tachiyomi.ui.anime.episode + +import android.text.SpannableStringBuilder +import android.view.View +import androidx.core.text.buildSpannedString +import androidx.core.text.color +import androidx.core.view.isVisible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.databinding.EpisodesItemBinding +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.ui.anime.episode.base.BaseEpisodeHolder +import java.util.Date + +class EpisodeHolder( + view: View, + private val adapter: EpisodesAdapter +) : BaseEpisodeHolder(view, adapter) { + + private val binding = EpisodesItemBinding.bind(view) + + init { + binding.animedownload.setOnClickListener { + onAnimeDownloadClick(it, bindingAdapterPosition) + } + } + + fun bind(item: EpisodeItem, anime: Anime) { + val episode = item.episode + + binding.episodeTitle.text = when (anime.displayMode) { + Anime.DISPLAY_NUMBER -> { + val number = adapter.decimalFormat.format(episode.episode_number.toDouble()) + itemView.context.getString(R.string.display_mode_chapter, number) + } + else -> episode.name + } + + // Set correct text color + val episodeTitleColor = when { + episode.read -> adapter.readColor + episode.bookmark -> adapter.bookmarkedColor + else -> adapter.unreadColor + } + binding.episodeTitle.setTextColor(episodeTitleColor) + + val episodeDescriptionColor = when { + episode.read -> adapter.readColor + episode.bookmark -> adapter.bookmarkedColor + else -> adapter.unreadColorSecondary + } + binding.episodeDescription.setTextColor(episodeDescriptionColor) + + binding.bookmarkIcon.isVisible = episode.bookmark + + val descriptions = mutableListOf() + + if (episode.date_upload > 0) { + descriptions.add(adapter.dateFormat.format(Date(episode.date_upload))) + } + if (!episode.read && episode.last_page_read > 0) { + val lastPageRead = buildSpannedString { + color(adapter.readColor) { + append(itemView.context.getString(R.string.chapter_progress, episode.last_page_read + 1)) + } + } + descriptions.add(lastPageRead) + } + if (!episode.scanlator.isNullOrBlank()) { + descriptions.add(episode.scanlator!!) + } + + if (descriptions.isNotEmpty()) { + binding.episodeDescription.text = descriptions.joinTo(SpannableStringBuilder(), " • ") + } else { + binding.episodeDescription.text = "" + } + + binding.animedownload.isVisible = item.anime.source != LocalSource.ID + binding.animedownload.setState(item.status, item.progress) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeItem.kt new file mode 100644 index 000000000..68a5208d3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodeItem.kt @@ -0,0 +1,33 @@ +package eu.kanade.tachiyomi.ui.anime.episode + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractHeaderItem +import eu.davidea.flexibleadapter.items.IFlexible +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Episode +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.ui.anime.episode.base.BaseEpisodeItem + +class EpisodeItem(episode: Episode, val anime: Anime) : + BaseEpisodeItem>(episode) { + + override fun getLayoutRes(): Int { + return R.layout.episodes_item + } + + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): EpisodeHolder { + return EpisodeHolder(view, adapter as EpisodesAdapter) + } + + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: EpisodeHolder, + position: Int, + payloads: List? + ) { + holder.bind(this, anime) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodesAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodesAdapter.kt new file mode 100644 index 000000000..784f4d22d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodesAdapter.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.ui.anime.episode + +import android.content.Context +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.anime.AnimeController +import eu.kanade.tachiyomi.ui.anime.episode.base.BaseEpisodesAdapter +import eu.kanade.tachiyomi.util.system.getResourceColor +import uy.kohesive.injekt.injectLazy +import java.text.DateFormat +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols + +class EpisodesAdapter( + controller: AnimeController, + context: Context +) : BaseEpisodesAdapter(controller) { + + private val preferences: PreferencesHelper by injectLazy() + + var items: List = emptyList() + + val readColor = context.getResourceColor(R.attr.colorOnSurface, 0.38f) + val unreadColor = context.getResourceColor(R.attr.colorOnSurface) + val unreadColorSecondary = context.getResourceColor(android.R.attr.textColorSecondary) + + val bookmarkedColor = context.getResourceColor(R.attr.colorAccent) + + val decimalFormat = DecimalFormat( + "#.###", + DecimalFormatSymbols() + .apply { decimalSeparator = '.' } + ) + + val dateFormat: DateFormat = preferences.dateFormat() + + override fun updateDataSet(items: List?) { + this.items = items ?: emptyList() + super.updateDataSet(items) + } + + fun indexOf(item: EpisodeItem): Int { + return items.indexOf(item) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodesSettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodesSettingsSheet.kt new file mode 100644 index 000000000..7d3540a5a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/EpisodesSettingsSheet.kt @@ -0,0 +1,269 @@ +package eu.kanade.tachiyomi.ui.anime.episode + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.core.view.isVisible +import com.bluelinelabs.conductor.Router +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.ui.anime.AnimePresenter +import eu.kanade.tachiyomi.util.view.popupMenu +import eu.kanade.tachiyomi.widget.ExtendedNavigationView +import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State +import eu.kanade.tachiyomi.widget.sheet.TabbedBottomSheetDialog + +class EpisodesSettingsSheet( + private val router: Router, + private val presenter: AnimePresenter, + onGroupClickListener: (ExtendedNavigationView.Group) -> Unit +) : TabbedBottomSheetDialog(router.activity!!) { + + val filters: Filter + private val sort: Sort + private val display: Display + + init { + filters = Filter(router.activity!!) + filters.onGroupClicked = onGroupClickListener + + sort = Sort(router.activity!!) + sort.onGroupClicked = onGroupClickListener + + display = Display(router.activity!!) + display.onGroupClicked = onGroupClickListener + + binding.menu.isVisible = true + binding.menu.setOnClickListener { it.post { showPopupMenu(it) } } + } + + override fun getTabViews(): List = listOf( + filters, + sort, + display + ) + + override fun getTabTitles(): List = listOf( + R.string.action_filter, + R.string.action_sort, + R.string.action_display + ) + + private fun showPopupMenu(view: View) { + view.popupMenu( + R.menu.default_chapter_filter, + { + }, + { + when (this.itemId) { + R.id.set_as_default -> { + SetEpisodeSettingsDialog(presenter.anime).showDialog(router) + true + } + else -> true + } + } + ) + } + + /** + * Filters group (unread, downloaded, ...). + */ + inner class Filter @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + Settings(context, attrs) { + + private val filterGroup = FilterGroup() + + init { + setGroups(listOf(filterGroup)) + } + + /** + * Returns true if there's at least one filter from [FilterGroup] active. + */ + fun hasActiveFilters(): Boolean { + return filterGroup.items.any { it.state != State.IGNORE.value } + } + + inner class FilterGroup : Group { + + private val downloaded = Item.TriStateGroup(R.string.action_filter_downloaded, this) + private val unread = Item.TriStateGroup(R.string.action_filter_unread, this) + private val bookmarked = Item.TriStateGroup(R.string.action_filter_bookmarked, this) + + override val header = null + override val items = listOf(downloaded, unread, bookmarked) + override val footer = null + + override fun initModels() { + if (presenter.forceDownloaded()) { + downloaded.state = State.INCLUDE.value + downloaded.enabled = false + } else { + downloaded.state = presenter.onlyDownloaded().value + } + unread.state = presenter.onlyUnread().value + bookmarked.state = presenter.onlyBookmarked().value + } + + override fun onItemClicked(item: Item) { + item as Item.TriStateGroup + val newState = when (item.state) { + State.IGNORE.value -> State.INCLUDE + State.INCLUDE.value -> State.EXCLUDE + State.EXCLUDE.value -> State.IGNORE + else -> throw Exception("Unknown State") + } + item.state = newState.value + when (item) { + downloaded -> presenter.setDownloadedFilter(newState) + unread -> presenter.setUnreadFilter(newState) + bookmarked -> presenter.setBookmarkedFilter(newState) + } + + initModels() + item.group.items.forEach { adapter.notifyItemChanged(it) } + } + } + } + + /** + * Sorting group (alphabetically, by last read, ...) and ascending or descending. + */ + inner class Sort @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + Settings(context, attrs) { + + init { + setGroups(listOf(SortGroup())) + } + + inner class SortGroup : Group { + + private val source = Item.MultiSort(R.string.sort_by_source, this) + private val episodeNum = Item.MultiSort(R.string.sort_by_number, this) + private val uploadDate = Item.MultiSort(R.string.sort_by_upload_date, this) + + override val header = null + override val items = listOf(source, uploadDate, episodeNum) + override val footer = null + + override fun initModels() { + val sorting = presenter.anime.sorting + val order = if (presenter.anime.sortDescending()) { + Item.MultiSort.SORT_DESC + } else { + Item.MultiSort.SORT_ASC + } + + source.state = + if (sorting == Anime.SORTING_SOURCE) order else Item.MultiSort.SORT_NONE + episodeNum.state = + if (sorting == Anime.SORTING_NUMBER) order else Item.MultiSort.SORT_NONE + uploadDate.state = + if (sorting == Anime.SORTING_UPLOAD_DATE) order else Item.MultiSort.SORT_NONE + } + + override fun onItemClicked(item: Item) { + item as Item.MultiStateGroup + val prevState = item.state + + item.group.items.forEach { + (it as Item.MultiStateGroup).state = + Item.MultiSort.SORT_NONE + } + item.state = when (prevState) { + Item.MultiSort.SORT_NONE -> Item.MultiSort.SORT_ASC + Item.MultiSort.SORT_ASC -> Item.MultiSort.SORT_DESC + Item.MultiSort.SORT_DESC -> Item.MultiSort.SORT_ASC + else -> throw Exception("Unknown state") + } + + when (item) { + source -> presenter.setSorting(Anime.SORTING_SOURCE) + episodeNum -> presenter.setSorting(Anime.SORTING_NUMBER) + uploadDate -> presenter.setSorting(Anime.SORTING_UPLOAD_DATE) + else -> throw Exception("Unknown sorting") + } + + presenter.reverseSortOrder() + + item.group.items.forEach { adapter.notifyItemChanged(it) } + } + } + } + + /** + * Display group, to show the library as a list or a grid. + */ + inner class Display @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + Settings(context, attrs) { + + init { + setGroups(listOf(DisplayGroup())) + } + + inner class DisplayGroup : Group { + + private val displayTitle = Item.Radio(R.string.show_title, this) + private val displayEpisodeNum = Item.Radio(R.string.show_chapter_number, this) + + override val header = null + override val items = listOf(displayTitle, displayEpisodeNum) + override val footer = null + + override fun initModels() { + val mode = presenter.anime.displayMode + displayTitle.checked = mode == Anime.DISPLAY_NAME + displayEpisodeNum.checked = mode == Anime.DISPLAY_NUMBER + } + + override fun onItemClicked(item: Item) { + item as Item.Radio + if (item.checked) return + + item.group.items.forEach { (it as Item.Radio).checked = false } + item.checked = true + + when (item) { + displayTitle -> presenter.setDisplayMode(Anime.DISPLAY_NAME) + displayEpisodeNum -> presenter.setDisplayMode(Anime.DISPLAY_NUMBER) + else -> throw NotImplementedError("Unknown display mode") + } + + item.group.items.forEach { adapter.notifyItemChanged(it) } + } + } + } + + open inner class Settings(context: Context, attrs: AttributeSet?) : + ExtendedNavigationView(context, attrs) { + + lateinit var adapter: Adapter + + /** + * Click listener to notify the parent fragment when an item from a group is clicked. + */ + var onGroupClicked: (Group) -> Unit = {} + + fun setGroups(groups: List) { + adapter = Adapter(groups.map { it.createItems() }.flatten()) + recycler.adapter = adapter + + groups.forEach { it.initModels() } + addView(recycler) + } + + /** + * Adapter of the recycler view. + */ + inner class Adapter(items: List) : ExtendedNavigationView.Adapter(items) { + + override fun onItemClicked(item: Item) { + if (item is GroupedItem) { + item.group.onItemClicked(item) + onGroupClicked(item.group) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/SetEpisodeSettingsDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/SetEpisodeSettingsDialog.kt new file mode 100644 index 000000000..b97659d6b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/SetEpisodeSettingsDialog.kt @@ -0,0 +1,47 @@ +package eu.kanade.tachiyomi.ui.anime.episode + +import android.app.Dialog +import android.os.Bundle +import androidx.core.os.bundleOf +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.util.episode.EpisodeSettingsHelper +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.widget.DialogCheckboxView + +class SetEpisodeSettingsDialog(bundle: Bundle? = null) : DialogController(bundle) { + + constructor(anime: Anime) : this( + bundleOf(MANGA_KEY to anime) + ) + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val view = DialogCheckboxView(activity!!).apply { + setDescription(R.string.confirm_set_chapter_settings) + setOptionDescription(R.string.also_set_chapter_settings_for_library) + } + + return MaterialDialog(activity!!) + .title(R.string.chapter_settings) + .customView( + view = view, + horizontalPadding = true + ) + .positiveButton(android.R.string.ok) { + EpisodeSettingsHelper.setGlobalSettings(args.getSerializable(MANGA_KEY)!! as Anime) + if (view.isChecked()) { + EpisodeSettingsHelper.updateAllAnimesWithGlobalDefaults() + } + + activity?.toast(activity!!.getString(R.string.chapter_settings_updated)) + } + .negativeButton(android.R.string.cancel) + } + + private companion object { + const val MANGA_KEY = "anime" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodeHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodeHolder.kt new file mode 100644 index 000000000..9cd3d5235 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodeHolder.kt @@ -0,0 +1,38 @@ +package eu.kanade.tachiyomi.ui.anime.episode.base + +import android.view.View +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.download.model.AnimeDownload +import eu.kanade.tachiyomi.util.view.popupMenu + +open class BaseEpisodeHolder( + view: View, + private val adapter: BaseEpisodesAdapter<*> +) : FlexibleViewHolder(view, adapter) { + + fun onAnimeDownloadClick(view: View, position: Int) { + val item = adapter.getItem(position) as? BaseEpisodeItem<*, *> ?: return + when (item.status) { + AnimeDownload.State.NOT_DOWNLOADED, AnimeDownload.State.ERROR -> { + adapter.clickListener.downloadEpisode(position) + } + else -> { + view.popupMenu( + R.menu.chapter_download, + initMenu = { + // AnimeDownload.State.DOWNLOADED + findItem(R.id.delete_download).isVisible = item.status == AnimeDownload.State.DOWNLOADED + + // AnimeDownload.State.DOWNLOADING, AnimeDownload.State.QUEUE + findItem(R.id.cancel_download).isVisible = item.status != AnimeDownload.State.DOWNLOADED + }, + onMenuItemClick = { + adapter.clickListener.deleteEpisode(position) + true + } + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodeItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodeItem.kt new file mode 100644 index 000000000..a29ab4683 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodeItem.kt @@ -0,0 +1,47 @@ +package eu.kanade.tachiyomi.ui.anime.episode.base + +import eu.davidea.flexibleadapter.items.AbstractHeaderItem +import eu.davidea.flexibleadapter.items.AbstractSectionableItem +import eu.kanade.tachiyomi.data.database.models.Episode +import eu.kanade.tachiyomi.data.download.model.AnimeDownload +import eu.kanade.tachiyomi.source.model.Page + +abstract class BaseEpisodeItem>( + val episode: Episode, + header: H? = null +) : + AbstractSectionableItem(header), + Episode by episode { + + private var _status: AnimeDownload.State = AnimeDownload.State.NOT_DOWNLOADED + + var status: AnimeDownload.State + get() = download?.status ?: _status + set(value) { + _status = value + } + + val progress: Int + get() { + val pages = download?.pages ?: return 0 + return pages.map(Page::progress).average().toInt() + } + + @Transient + var download: AnimeDownload? = null + + val isDownloaded: Boolean + get() = status == AnimeDownload.State.DOWNLOADED + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is BaseEpisodeItem<*, *>) { + return episode.id!! == other.episode.id!! + } + return false + } + + override fun hashCode(): Int { + return episode.id!!.hashCode() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodesAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodesAdapter.kt new file mode 100644 index 000000000..d2ce56856 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/episode/base/BaseEpisodesAdapter.kt @@ -0,0 +1,22 @@ +package eu.kanade.tachiyomi.ui.anime.episode.base + +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.IFlexible + +abstract class BaseEpisodesAdapter>( + controller: OnEpisodeClickListener +) : FlexibleAdapter(null, controller, true) { + + /** + * Listener for browse item clicks. + */ + val clickListener: OnEpisodeClickListener = controller + + /** + * Listener which should be called when user clicks the download icons. + */ + interface OnEpisodeClickListener { + fun downloadEpisode(position: Int) + fun deleteEpisode(position: Int) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/info/AnimeCoverImageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/info/AnimeCoverImageView.kt new file mode 100644 index 000000000..6cdf62e53 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/info/AnimeCoverImageView.kt @@ -0,0 +1,24 @@ +package eu.kanade.tachiyomi.ui.manga.info + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import kotlin.math.min + +/** + * A custom ImageView for holding a manga cover with: + * - width: min(maxWidth attr, 33% of parent width) + * - height: 2:3 width:height ratio + * + * Should be defined with a width of match_parent. + */ +class AnimeCoverImageView(context: Context, attrs: AttributeSet?) : AppCompatImageView(context, attrs) { + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + val width = min(maxWidth, measuredWidth / 3) + val height = width / 2 * 3 + setMeasuredDimension(width, height) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/info/AnimeInfoHeaderAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/info/AnimeInfoHeaderAdapter.kt new file mode 100644 index 000000000..1f3e29686 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/info/AnimeInfoHeaderAdapter.kt @@ -0,0 +1,358 @@ +package eu.kanade.tachiyomi.ui.anime.info + +import android.graphics.PorterDuff +import android.os.Build +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.glide.AnimeThumbnail +import eu.kanade.tachiyomi.data.glide.toAnimeThumbnail +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.databinding.AnimeInfoHeaderBinding +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.model.SAnime +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.ui.anime.AnimeController +import eu.kanade.tachiyomi.util.system.copyToClipboard +import eu.kanade.tachiyomi.util.system.getResourceColor +import eu.kanade.tachiyomi.util.view.setChips +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.view.clicks +import reactivecircus.flowbinding.android.view.longClicks +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy + +class AnimeInfoHeaderAdapter( + private val controller: AnimeController, + private val fromSource: Boolean +) : + RecyclerView.Adapter() { + + private val trackManager: TrackManager by injectLazy() + + private var anime: Anime = controller.presenter.anime + private var source: Source = controller.presenter.source + private var trackCount: Int = 0 + + private lateinit var binding: AnimeInfoHeaderBinding + + private var initialLoad: Boolean = true + private var currentAnimeThumbnail: AnimeThumbnail? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { + binding = AnimeInfoHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return HeaderViewHolder(binding.root) + } + + override fun getItemCount(): Int = 1 + + override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) { + holder.bind() + } + + /** + * Update the view with anime information. + * + * @param anime anime object containing information about anime. + * @param source the source of the anime. + */ + fun update(anime: Anime, source: Source) { + this.anime = anime + this.source = source + + notifyDataSetChanged() + } + + fun setTrackingCount(trackCount: Int) { + this.trackCount = trackCount + + notifyDataSetChanged() + } + + inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) { + fun bind() { + // For rounded corners + binding.animeCover.clipToOutline = true + + binding.btnFavorite.clicks() + .onEach { controller.onFavoriteClick() } + .launchIn(controller.viewScope) + + if (controller.presenter.anime.favorite && controller.presenter.getCategories().isNotEmpty()) { + binding.btnFavorite.longClicks() + .onEach { controller.onCategoriesClick() } + .launchIn(controller.viewScope) + } + + with(binding.btnTracking) { + if (trackManager.hasLoggedServices()) { + isVisible = true + + if (trackCount > 0) { + setIconResource(R.drawable.ic_done_24dp) + text = view.context.resources.getQuantityString( + R.plurals.num_trackers, + trackCount, + trackCount + ) + isActivated = true + } else { + setIconResource(R.drawable.ic_sync_24dp) + text = view.context.getString(R.string.manga_tracking_tab) + isActivated = false + } + + clicks() + .onEach { controller.onTrackingClick() } + .launchIn(controller.viewScope) + } else { + isVisible = false + } + } + + if (controller.presenter.source is HttpSource) { + binding.btnWebview.isVisible = true + binding.btnWebview.clicks() + .onEach { controller.openAnimeInWebView() } + .launchIn(controller.viewScope) + } + + binding.animeFullTitle.longClicks() + .onEach { + controller.activity?.copyToClipboard( + view.context.getString(R.string.title), + binding.animeFullTitle.text.toString() + ) + } + .launchIn(controller.viewScope) + + binding.animeFullTitle.clicks() + .onEach { + controller.performGlobalSearch(binding.animeFullTitle.text.toString()) + } + .launchIn(controller.viewScope) + + binding.animeAuthor.longClicks() + .onEach { + controller.activity?.copyToClipboard( + binding.animeAuthor.text.toString(), + binding.animeAuthor.text.toString() + ) + } + .launchIn(controller.viewScope) + + binding.animeAuthor.clicks() + .onEach { + controller.performGlobalSearch(binding.animeAuthor.text.toString()) + } + .launchIn(controller.viewScope) + + binding.animeArtist.longClicks() + .onEach { + controller.activity?.copyToClipboard( + binding.animeArtist.text.toString(), + binding.animeArtist.text.toString() + ) + } + .launchIn(controller.viewScope) + + binding.animeArtist.clicks() + .onEach { + controller.performGlobalSearch(binding.animeArtist.text.toString()) + } + .launchIn(controller.viewScope) + + binding.animeSummaryText.longClicks() + .onEach { + controller.activity?.copyToClipboard( + view.context.getString(R.string.description), + binding.animeSummaryText.text.toString() + ) + } + .launchIn(controller.viewScope) + + binding.animeCover.longClicks() + .onEach { + controller.activity?.copyToClipboard( + view.context.getString(R.string.title), + controller.presenter.anime.title + ) + } + .launchIn(controller.viewScope) + + setAnimeInfo(anime, source) + } + + /** + * Update the view with anime information. + * + * @param anime anime object containing information about anime. + * @param source the source of the anime. + */ + private fun setAnimeInfo(anime: Anime, source: Source?) { + // Update full title TextView. + binding.animeFullTitle.text = if (anime.title.isBlank()) { + view.context.getString(R.string.unknown) + } else { + anime.title + } + + // Update author TextView. + binding.animeAuthor.text = if (anime.author.isNullOrBlank()) { + view.context.getString(R.string.unknown_author) + } else { + anime.author + } + + // Update artist TextView. + val hasArtist = !anime.artist.isNullOrBlank() && anime.artist != anime.author + binding.animeArtist.isVisible = hasArtist + if (hasArtist) { + binding.animeArtist.text = anime.artist + } + + // If anime source is known update source TextView. + val animeSource = source?.toString() + with(binding.animeSource) { + if (animeSource != null) { + text = animeSource + setOnClickListener { + val sourceManager = Injekt.get() + controller.performSearch(sourceManager.getOrStub(source.id).name) + } + } else { + text = view.context.getString(R.string.unknown) + } + } + + // Update status TextView. + binding.animeStatus.setText( + when (anime.status) { + SAnime.ONGOING -> R.string.ongoing + SAnime.COMPLETED -> R.string.completed + SAnime.LICENSED -> R.string.licensed + else -> R.string.unknown_status + } + ) + + // Set the favorite drawable to the correct one. + setFavoriteButtonState(anime.favorite) + + // Set cover if changed. + val animeThumbnail = anime.toAnimeThumbnail() + if (animeThumbnail != currentAnimeThumbnail) { + currentAnimeThumbnail = animeThumbnail + listOf(binding.animeCover, binding.backdrop) + .forEach { + GlideApp.with(view.context) + .load(animeThumbnail) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .into(it) + } + } + + // Anime info section + val hasInfoContent = !anime.description.isNullOrBlank() || !anime.genre.isNullOrBlank() + showAnimeInfo(hasInfoContent) + if (hasInfoContent) { + // Update description TextView. + binding.animeSummaryText.text = if (anime.description.isNullOrBlank()) { + view.context.getString(R.string.unknown) + } else { + anime.description + } + + // Update genres list + if (!anime.genre.isNullOrBlank()) { + binding.animeGenresTagsCompactChips.setChips( + anime.getGenres(), + controller::performSearch + ) + binding.animeGenresTagsFullChips.setChips( + anime.getGenres(), + controller::performSearch + ) + } else { + binding.animeGenresTagsCompactChips.isVisible = false + binding.animeGenresTagsFullChips.isVisible = false + } + + // Handle showing more or less info + merge( + binding.animeSummarySection.clicks(), + binding.animeSummaryText.clicks(), + binding.animeInfoToggleMore.clicks(), + binding.animeInfoToggleLess.clicks() + ) + .onEach { toggleAnimeInfo() } + .launchIn(controller.viewScope) + + // Expand anime info if navigated from source listing + if (initialLoad && fromSource) { + toggleAnimeInfo() + initialLoad = false + } + } + + // backgroundTint attribute doesn't work properly on Android 5 + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP) { + listOf(binding.backdropOverlay, binding.animeInfoToggleMoreScrim) + .forEach { + @Suppress("DEPRECATION") + it.background.setColorFilter( + view.context.getResourceColor(android.R.attr.colorBackground), + PorterDuff.Mode.SRC_ATOP + ) + } + } + } + + private fun showAnimeInfo(visible: Boolean) { + binding.animeSummarySection.isVisible = visible + } + + private fun toggleAnimeInfo() { + val isCurrentlyExpanded = binding.animeSummaryText.maxLines != 2 + + binding.animeInfoToggleMoreScrim.isVisible = isCurrentlyExpanded + binding.animeInfoToggleMore.isVisible = isCurrentlyExpanded + binding.animeInfoToggleLess.isVisible = !isCurrentlyExpanded + + binding.animeSummaryText.maxLines = if (isCurrentlyExpanded) { + 2 + } else { + Int.MAX_VALUE + } + + binding.animeGenresTagsCompact.isVisible = isCurrentlyExpanded + binding.animeGenresTagsFullChips.isVisible = !isCurrentlyExpanded + } + + /** + * Update favorite button with correct drawable and text. + * + * @param isFavorite determines if anime is favorite or not. + */ + private fun setFavoriteButtonState(isFavorite: Boolean) { + // Set the Favorite drawable to the correct one. + // Border drawable if false, filled drawable if true. + binding.btnFavorite.apply { + setIconResource(if (isFavorite) R.drawable.ic_favorite_24dp else R.drawable.ic_favorite_border_24dp) + text = + context.getString(if (isFavorite) R.string.in_library else R.string.add_to_library) + isActivated = isFavorite + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackEpisodesDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackEpisodesDialog.kt new file mode 100644 index 000000000..063613d79 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackEpisodesDialog.kt @@ -0,0 +1,79 @@ +package eu.kanade.tachiyomi.ui.anime.track + +import android.app.Dialog +import android.os.Bundle +import android.widget.NumberPicker +import androidx.core.os.bundleOf +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView +import com.afollestad.materialdialogs.customview.getCustomView +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.AnimeTrack +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class SetTrackEpisodesDialog : DialogController + where T : Controller { + + private val item: TrackItem + + private lateinit var listener: Listener + + constructor(target: T, listener: Listener, item: TrackItem) : super( + bundleOf(KEY_ITEM_TRACK to item.track) + ) { + targetController = target + this.listener = listener + this.item = item + } + + @Suppress("unused") + constructor(bundle: Bundle) : super(bundle) { + val track = bundle.getSerializable(KEY_ITEM_TRACK) as AnimeTrack + val service = Injekt.get().getService(track.sync_id)!! + item = TrackItem(track, service) + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val item = item + + val dialog = MaterialDialog(activity!!) + .title(R.string.chapters) + .customView(R.layout.track_chapters_dialog, dialogWrapContent = false) + .positiveButton(android.R.string.ok) { dialog -> + val view = dialog.getCustomView() + // Remove focus to update selected number + val np: NumberPicker = view.findViewById(R.id.chapters_picker) + np.clearFocus() + + listener.setEpisodesRead(item, np.value) + } + .negativeButton(android.R.string.cancel) + + val view = dialog.getCustomView() + val np: NumberPicker = view.findViewById(R.id.chapters_picker) + // Set initial value + np.value = item.track?.last_episode_seen ?: 0 + + // Enforce maximum value if tracker has total number of chapters set + if (item.track != null && item.track.total_episodes > 0) { + np.maxValue = item.track.total_episodes + } + + // Don't allow to go from 0 to 9999 + np.wrapSelectorWheel = false + + return dialog + } + + interface Listener { + fun setEpisodesRead(item: TrackItem, chaptersRead: Int) + } + + private companion object { + const val KEY_ITEM_TRACK = "SetTrackChaptersDialog.item.track" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackScoreDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackScoreDialog.kt new file mode 100644 index 000000000..e0f41006b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackScoreDialog.kt @@ -0,0 +1,79 @@ +package eu.kanade.tachiyomi.ui.anime.track + +import android.app.Dialog +import android.os.Bundle +import android.widget.NumberPicker +import androidx.core.os.bundleOf +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView +import com.afollestad.materialdialogs.customview.getCustomView +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.AnimeTrack +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class SetTrackScoreDialog : DialogController + where T : Controller { + + private val item: TrackItem + + private lateinit var listener: Listener + + constructor(target: T, listener: Listener, item: TrackItem) : super( + bundleOf(KEY_ITEM_TRACK to item.track) + ) { + targetController = target + this.listener = listener + this.item = item + } + + @Suppress("unused") + constructor(bundle: Bundle) : super(bundle) { + val track = bundle.getSerializable(KEY_ITEM_TRACK) as AnimeTrack + val service = Injekt.get().getService(track.sync_id)!! + item = TrackItem(track, service) + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val item = item + + val dialog = MaterialDialog(activity!!) + .title(R.string.score) + .customView(R.layout.track_score_dialog, dialogWrapContent = false) + .positiveButton(android.R.string.ok) { dialog -> + val view = dialog.getCustomView() + // Remove focus to update selected number + val np: NumberPicker = view.findViewById(R.id.score_picker) + np.clearFocus() + + listener.setScore(item, np.value) + } + .negativeButton(android.R.string.cancel) + + val view = dialog.getCustomView() + val np: NumberPicker = view.findViewById(R.id.score_picker) + val scores = item.service.getScoreList().toTypedArray() + np.maxValue = scores.size - 1 + np.displayedValues = scores + + // Set initial value + val displayedScore = item.service.displayScore(item.track!!) + if (displayedScore != "-") { + val index = scores.indexOf(displayedScore) + np.value = if (index != -1) index else 0 + } + + return dialog + } + + interface Listener { + fun setScore(item: TrackItem, score: Int) + } + + private companion object { + const val KEY_ITEM_TRACK = "SetTrackScoreDialog.item.track" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackStatusDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackStatusDialog.kt new file mode 100644 index 000000000..89ebbd98b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackStatusDialog.kt @@ -0,0 +1,64 @@ +package eu.kanade.tachiyomi.ui.anime.track + +import android.app.Dialog +import android.os.Bundle +import androidx.core.os.bundleOf +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItemsSingleChoice +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.AnimeTrack +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class SetTrackStatusDialog : DialogController + where T : Controller { + + private val item: TrackItem + + private lateinit var listener: Listener + + constructor(target: T, listener: Listener, item: TrackItem) : super( + bundleOf(KEY_ITEM_TRACK to item.track) + ) { + targetController = target + this.listener = listener + this.item = item + } + + @Suppress("unused") + constructor(bundle: Bundle) : super(bundle) { + val track = bundle.getSerializable(KEY_ITEM_TRACK) as AnimeTrack + val service = Injekt.get().getService(track.sync_id)!! + item = TrackItem(track, service) + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val item = item + val statusList = item.service.getStatusList() + val statusString = statusList.map { item.service.getStatus(it) } + val selectedIndex = statusList.indexOf(item.track?.status) + + return MaterialDialog(activity!!) + .title(R.string.status) + .negativeButton(android.R.string.cancel) + .listItemsSingleChoice( + items = statusString, + initialSelection = selectedIndex, + waitForPositiveButton = false + ) { dialog, position, _ -> + listener.setStatus(item, position) + dialog.dismiss() + } + } + + interface Listener { + fun setStatus(item: TrackItem, selection: Int) + } + + private companion object { + const val KEY_ITEM_TRACK = "SetTrackStatusDialog.item.track" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackWatchingDatesDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackWatchingDatesDialog.kt new file mode 100644 index 000000000..ad5f015d6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/SetTrackWatchingDatesDialog.kt @@ -0,0 +1,86 @@ +package eu.kanade.tachiyomi.ui.anime.track + +import android.app.Dialog +import android.os.Bundle +import androidx.core.os.bundleOf +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.datetime.datePicker +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.Calendar + +class SetTrackWatchingDatesDialog : DialogController + where T : Controller { + + private val item: TrackItem + + private val dateToUpdate: ReadingDate + + private lateinit var listener: Listener + + constructor(target: T, listener: Listener, dateToUpdate: ReadingDate, item: TrackItem) : super( + bundleOf(KEY_ITEM_TRACK to item.track) + ) { + targetController = target + this.listener = listener + this.item = item + this.dateToUpdate = dateToUpdate + } + + @Suppress("unused") + constructor(bundle: Bundle) : super(bundle) { + val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track + val service = Injekt.get().getService(track.sync_id)!! + item = TrackItem(track, service) + dateToUpdate = ReadingDate.Start + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + return MaterialDialog(activity!!) + .title( + when (dateToUpdate) { + ReadingDate.Start -> R.string.track_started_reading_date + ReadingDate.Finish -> R.string.track_finished_reading_date + } + ) + .datePicker(currentDate = getCurrentDate()) { _, date -> + listener?.setReadingDate(item, dateToUpdate, date.timeInMillis) + } + .neutralButton(R.string.action_remove) { + listener?.setReadingDate(item, dateToUpdate, 0L) + } + } + + private fun getCurrentDate(): Calendar { + // Today if no date is set, otherwise the already set date + return Calendar.getInstance().apply { + item.track?.let { + val date = when (dateToUpdate) { + ReadingDate.Start -> it.started_reading_date + ReadingDate.Finish -> it.finished_reading_date + } + if (date != 0L) { + timeInMillis = date + } + } + } + } + + interface Listener { + fun setReadingDate(item: TrackItem, type: ReadingDate, date: Long) + } + + enum class ReadingDate { + Start, + Finish + } + + companion object { + private const val KEY_ITEM_TRACK = "SetTrackReadingDatesDialog.item.track" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackAdapter.kt new file mode 100644 index 000000000..85ec20b9d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackAdapter.kt @@ -0,0 +1,49 @@ +package eu.kanade.tachiyomi.ui.anime.track + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import eu.kanade.tachiyomi.databinding.TrackItemBinding + +class TrackAdapter(listener: OnClickListener) : RecyclerView.Adapter() { + + private lateinit var binding: TrackItemBinding + + var items = emptyList() + set(value) { + if (field !== value) { + field = value + notifyDataSetChanged() + } + } + + val rowClickListener: OnClickListener = listener + + fun getItem(index: Int): TrackItem? { + return items.getOrNull(index) + } + + override fun getItemCount(): Int { + return items.size + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackHolder { + binding = TrackItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return TrackHolder(binding, this) + } + + override fun onBindViewHolder(holder: TrackHolder, position: Int) { + holder.bind(items[position]) + } + + interface OnClickListener { + fun onLogoClick(position: Int) + fun onSetClick(position: Int) + fun onTitleLongClick(position: Int) + fun onStatusClick(position: Int) + fun onEpisodesClick(position: Int) + fun onScoreClick(position: Int) + fun onStartDateClick(position: Int) + fun onFinishDateClick(position: Int) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackHolder.kt new file mode 100644 index 000000000..0fc81fdad --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackHolder.kt @@ -0,0 +1,66 @@ +package eu.kanade.tachiyomi.ui.anime.track + +import android.annotation.SuppressLint +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.databinding.TrackItemBinding +import uy.kohesive.injekt.injectLazy +import java.text.DateFormat + +class TrackHolder(private val binding: TrackItemBinding, adapter: TrackAdapter) : RecyclerView.ViewHolder(binding.root) { + + private val preferences: PreferencesHelper by injectLazy() + + private val dateFormat: DateFormat by lazy { + preferences.dateFormat() + } + + init { + val listener = adapter.rowClickListener + + binding.logoContainer.setOnClickListener { listener.onLogoClick(bindingAdapterPosition) } + binding.trackSet.setOnClickListener { listener.onSetClick(bindingAdapterPosition) } + binding.trackTitle.setOnClickListener { listener.onSetClick(bindingAdapterPosition) } + binding.trackTitle.setOnLongClickListener { + listener.onTitleLongClick(bindingAdapterPosition) + true + } + binding.trackStatus.setOnClickListener { listener.onStatusClick(bindingAdapterPosition) } + binding.trackChapters.setOnClickListener { listener.onChaptersClick(bindingAdapterPosition) } + binding.trackScore.setOnClickListener { listener.onScoreClick(bindingAdapterPosition) } + binding.trackStartDate.setOnClickListener { listener.onStartDateClick(bindingAdapterPosition) } + binding.trackFinishDate.setOnClickListener { listener.onFinishDateClick(bindingAdapterPosition) } + } + + @SuppressLint("SetTextI18n") + fun bind(item: TrackItem) { + val track = item.track + binding.trackLogo.setImageResource(item.service.getLogo()) + binding.logoContainer.setBackgroundColor(item.service.getLogoColor()) + + binding.trackSet.isVisible = track == null + binding.trackTitle.isVisible = track != null + + binding.trackDetails.isVisible = track != null + if (track != null) { + binding.trackTitle.text = track.title + binding.trackChapters.text = "${track.last_chapter_read}/" + + if (track.total_chapters > 0) track.total_chapters else "-" + binding.trackStatus.text = item.service.getStatus(track.status) + binding.trackScore.text = if (track.score == 0f) "-" else item.service.displayScore(track) + + if (item.service.supportsReadingDates) { + binding.trackStartDate.text = + if (track.started_reading_date != 0L) dateFormat.format(track.started_reading_date) else "-" + binding.trackFinishDate.text = + if (track.finished_reading_date != 0L) dateFormat.format(track.finished_reading_date) else "-" + } else { + binding.bottomDivider.isVisible = false + binding.vertDivider3.isVisible = false + binding.trackStartDate.isVisible = false + binding.trackFinishDate.isVisible = false + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackItem.kt new file mode 100644 index 000000000..daf6db793 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackItem.kt @@ -0,0 +1,6 @@ +package eu.kanade.tachiyomi.ui.anime.track + +import eu.kanade.tachiyomi.data.database.models.AnimeTrack +import eu.kanade.tachiyomi.data.track.TrackService + +data class TrackItem(val track: AnimeTrack?, val service: TrackService) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackSearchAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackSearchAdapter.kt new file mode 100644 index 000000000..799bf493a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackSearchAdapter.kt @@ -0,0 +1,80 @@ +package eu.kanade.tachiyomi.ui.anime.track + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import androidx.core.view.isVisible +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.databinding.TrackSearchItemBinding +import eu.kanade.tachiyomi.util.view.inflate + +class TrackSearchAdapter(context: Context) : + ArrayAdapter(context, R.layout.track_search_item, mutableListOf()) { + + override fun getView(position: Int, view: View?, parent: ViewGroup): View { + var v = view + // Get the data item for this position + val track = getItem(position)!! + // Check if an existing view is being reused, otherwise inflate the view + val holder: TrackSearchHolder // view lookup cache stored in tag + if (v == null) { + v = parent.inflate(R.layout.track_search_item) + holder = TrackSearchHolder(v) + v.tag = holder + } else { + holder = v.tag as TrackSearchHolder + } + holder.onSetValues(track) + return v + } + + fun setItems(syncs: List) { + setNotifyOnChange(false) + clear() + addAll(syncs) + notifyDataSetChanged() + } + + class TrackSearchHolder(private val view: View) { + + private val binding = TrackSearchItemBinding.bind(view) + + fun onSetValues(track: TrackSearch) { + binding.trackSearchTitle.text = track.title + binding.trackSearchSummary.text = track.summary + GlideApp.with(view.context).clear(binding.trackSearchCover) + if (track.cover_url.isNotEmpty()) { + GlideApp.with(view.context) + .load(track.cover_url) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .into(binding.trackSearchCover) + } + + val hasStatus = track.publishing_status.isNotBlank() + binding.trackSearchStatus.isVisible = hasStatus + binding.trackSearchStatusResult.isVisible = hasStatus + if (hasStatus) { + binding.trackSearchStatusResult.text = track.publishing_status.capitalize() + } + + val hasType = track.publishing_type.isNotBlank() + binding.trackSearchType.isVisible = hasType + binding.trackSearchTypeResult.isVisible = hasType + if (hasType) { + binding.trackSearchTypeResult.text = track.publishing_type.capitalize() + } + + val hasStartDate = track.start_date.isNotBlank() + binding.trackSearchStart.isVisible = hasStartDate + binding.trackSearchStartResult.isVisible = hasStartDate + if (hasStartDate) { + binding.trackSearchStartResult.text = track.start_date + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackSearchDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackSearchDialog.kt new file mode 100644 index 000000000..94a5ab8c8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackSearchDialog.kt @@ -0,0 +1,138 @@ +package eu.kanade.tachiyomi.ui.anime.track + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.databinding.TrackSearchDialogBinding +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.ui.anime.AnimeController +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.widget.itemClicks +import reactivecircus.flowbinding.android.widget.textChanges +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.concurrent.TimeUnit + +class TrackSearchDialog : DialogController { + + private var binding: TrackSearchDialogBinding? = null + + private var adapter: TrackSearchAdapter? = null + + private var selectedItem: Track? = null + + private val service: TrackService + + private val trackController + get() = targetController as AnimeController + + constructor(target: AnimeController, service: TrackService) : super( + bundleOf(KEY_SERVICE to service.id) + ) { + targetController = target + this.service = service + } + + @Suppress("unused") + constructor(bundle: Bundle) : super(bundle) { + service = Injekt.get().getService(bundle.getInt(KEY_SERVICE))!! + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + binding = TrackSearchDialogBinding.inflate(LayoutInflater.from(activity!!)) + val dialog = MaterialDialog(activity!!) + .customView(view = binding!!.root) + .positiveButton(android.R.string.ok) { onPositiveButtonClick() } + .negativeButton(android.R.string.cancel) + .neutralButton(R.string.action_remove) { onRemoveButtonClick() } + + onViewCreated(dialog.view, savedViewState) + + return dialog + } + + fun onViewCreated(view: View, savedState: Bundle?) { + // Create adapter + val adapter = TrackSearchAdapter(view.context) + this.adapter = adapter + binding!!.trackSearchList.adapter = adapter + + // Set listeners + selectedItem = null + + binding!!.trackSearchList.itemClicks() + .onEach { position -> + selectedItem = adapter.getItem(position) + } + .launchIn(trackController.viewScope) + + // Do an initial search based on the manga's title + if (savedState == null) { + val title = trackController.presenter.anime.title + binding!!.trackSearch.append(title) + search(title) + } + } + + override fun onDestroyView(view: View) { + super.onDestroyView(view) + binding = null + adapter = null + } + + override fun onAttach(view: View) { + super.onAttach(view) + binding!!.trackSearch.textChanges() + .debounce(TimeUnit.SECONDS.toMillis(1)) + .filter { it.isNotBlank() } + .onEach { search(it.toString()) } + .launchIn(trackController.viewScope) + } + + private fun search(query: String) { + val binding = binding ?: return + binding.progress.isVisible = true + binding.trackSearchList.isVisible = false + trackController.presenter.trackingSearch(query, service) + } + + fun onSearchResults(results: List) { + selectedItem = null + val binding = binding ?: return + binding.progress.isVisible = false + binding.trackSearchList.isVisible = true + adapter?.setItems(results) + } + + fun onSearchResultsError() { + val binding = binding ?: return + binding.progress.isVisible = false + binding.trackSearchList.isVisible = false + adapter?.setItems(emptyList()) + } + + private fun onPositiveButtonClick() { + trackController.presenter.registerTracking(selectedItem, service) + } + + private fun onRemoveButtonClick() { + trackController.presenter.unregisterTracking(service) + } + + private companion object { + const val KEY_SERVICE = "service_id" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackSheet.kt new file mode 100644 index 000000000..daba63a04 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/track/TrackSheet.kt @@ -0,0 +1,143 @@ +package eu.kanade.tachiyomi.ui.anime.track + +import android.os.Bundle +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.bottomsheet.BottomSheetBehavior +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.databinding.TrackControllerBinding +import eu.kanade.tachiyomi.ui.base.controller.openInBrowser +import eu.kanade.tachiyomi.ui.anime.AnimeController +import eu.kanade.tachiyomi.util.system.copyToClipboard +import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog + +class TrackSheet( + val controller: AnimeController, + val anime: Anime +) : BaseBottomSheetDialog(controller.activity!!), + TrackAdapter.OnClickListener, + SetTrackStatusDialog.Listener, + SetTrackEpisodesDialog.Listener, + SetTrackScoreDialog.Listener, + SetTrackWatchingDatesDialog.Listener { + + private lateinit var binding: TrackControllerBinding + + private lateinit var sheetBehavior: BottomSheetBehavior<*> + + private lateinit var adapter: TrackAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = TrackControllerBinding.inflate(layoutInflater) + setContentView(binding.root) + + adapter = TrackAdapter(this) + binding.trackRecycler.layoutManager = LinearLayoutManager(context) + binding.trackRecycler.adapter = adapter + + sheetBehavior = BottomSheetBehavior.from(binding.root.parent as ViewGroup) + + adapter.items = controller.presenter.trackList + } + + override fun onStart() { + super.onStart() + sheetBehavior.skipCollapsed = true + } + + override fun show() { + super.show() + controller.presenter.refreshTrackers() + sheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED + } + + fun onNextTrackers(trackers: List) { + if (this::adapter.isInitialized) { + adapter.items = trackers + adapter.notifyDataSetChanged() + } + } + + override fun onLogoClick(position: Int) { + val track = adapter.getItem(position)?.track ?: return + + if (track.tracking_url.isNotBlank()) { + controller.openInBrowser(track.tracking_url) + } + } + + override fun onSetClick(position: Int) { + val item = adapter.getItem(position) ?: return + TrackSearchDialog(controller, item.service).showDialog(controller.router, TAG_SEARCH_CONTROLLER) + } + + override fun onTitleLongClick(position: Int) { + adapter.getItem(position)?.track?.title?.let { + controller.activity?.copyToClipboard(it, it) + } + } + + override fun onStatusClick(position: Int) { + val item = adapter.getItem(position) ?: return + if (item.track == null) return + + SetTrackStatusDialog(controller, this, item).showDialog(controller.router) + } + + override fun onEpisodesClick(position: Int) { + val item = adapter.getItem(position) ?: return + if (item.track == null) return + + SetTrackEpisodesDialog(controller, this, item).showDialog(controller.router) + } + + override fun onScoreClick(position: Int) { + val item = adapter.getItem(position) ?: return + if (item.track == null) return + + SetTrackScoreDialog(controller, this, item).showDialog(controller.router) + } + + override fun onStartDateClick(position: Int) { + val item = adapter.getItem(position) ?: return + if (item.track == null) return + + SetTrackWatchingDatesDialog(controller, this, SetTrackWatchingDatesDialog.ReadingDate.Start, item).showDialog(controller.router) + } + + override fun onFinishDateClick(position: Int) { + val item = adapter.getItem(position) ?: return + if (item.track == null) return + + SetTrackWatchingDatesDialog(controller, this, SetTrackWatchingDatesDialog.ReadingDate.Finish, item).showDialog(controller.router) + } + + override fun setStatus(item: TrackItem, selection: Int) { + controller.presenter.setTrackerStatus(item, selection) + } + + override fun setEpisodesRead(item: TrackItem, episodesRead: Int) { + controller.presenter.setTrackerLastEpisodeRead(item, episodesRead) + } + + override fun setScore(item: TrackItem, score: Int) { + controller.presenter.setTrackerScore(item, score) + } + + override fun setReadingDate(item: TrackItem, type: SetTrackWatchingDatesDialog.ReadingDate, date: Long) { + when (type) { + SetTrackWatchingDatesDialog.ReadingDate.Start -> controller.presenter.setTrackerStartDate(item, date) + SetTrackWatchingDatesDialog.ReadingDate.Finish -> controller.presenter.setTrackerFinishDate(item, date) + } + } + + fun getSearchDialog(): TrackSearchDialog? { + return controller.router.getControllerWithTag(TAG_SEARCH_CONTROLLER) as? TrackSearchDialog + } + + private companion object { + const val TAG_SEARCH_CONTROLLER = "track_search_controller" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt index d80fccb1e..acb6512a9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt @@ -50,6 +50,15 @@ open class BasePresenter : RxPresenter() { */ fun Observable.subscribeLatestCache(onNext: (V, T) -> Unit, onError: ((V, Throwable) -> Unit)? = null) = compose(deliverLatestCache()).subscribe(split(onNext, onError)).apply { add(this) } + /** + * Subscribes an observable with [deliverLatestCache] and adds it to the presenter's lifecycle + * subscription list. + * + * @param onNext function to execute when the observable emits an item. + * @param onError function to execute when the observable throws an error. + */ + fun Observable.subscribeLatestAnimeCache(onNext: (V, T) -> Unit, onError: ((V, Throwable) -> Unit)? = null) = compose(deliverLatestCache()).subscribe(split(onNext, onError)).apply { add(this) } + /** * Subscribes an observable with [deliverReplay] and adds it to the presenter's lifecycle * subscription list. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/AnimeMigrationFlags.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/AnimeMigrationFlags.kt new file mode 100644 index 000000000..3d0577e49 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/AnimeMigrationFlags.kt @@ -0,0 +1,38 @@ +package eu.kanade.tachiyomi.ui.browse.migration + +import eu.kanade.tachiyomi.R + +object AnimeMigrationFlags { + + private const val EPISODES = 0b001 + private const val CATEGORIES = 0b010 + private const val TRACK = 0b100 + + private const val EPISODES2 = 0x1 + private const val CATEGORIES2 = 0x2 + private const val TRACK2 = 0x4 + + val titles get() = arrayOf(R.string.chapters, R.string.categories, R.string.track) + + val flags get() = arrayOf(EPISODES, CATEGORIES, TRACK) + + fun hasEpisodes(value: Int): Boolean { + return value and EPISODES != 0 + } + + fun hasCategories(value: Int): Boolean { + return value and CATEGORIES != 0 + } + + fun hasTracks(value: Int): Boolean { + return value and TRACK != 0 + } + + fun getEnabledFlagsPositions(value: Int): List { + return flags.mapIndexedNotNull { index, flag -> if (value and flag != 0) index else null } + } + + fun getFlagsFromPositions(positions: Array): Int { + return positions.fold(0, { accumulated, position -> accumulated or (1 shl position) }) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaController.kt index a7fb20844..d4cfd4811 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaController.kt @@ -12,7 +12,7 @@ import eu.kanade.tachiyomi.databinding.MigrationMangaControllerBinding import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.AnimeController class MigrationMangaController : NucleusController, @@ -82,7 +82,7 @@ class MigrationMangaController : override fun onCoverClick(position: Int) { val mangaItem = adapter?.getItem(position) as? MigrationMangaItem ?: return - router.pushController(MangaController(mangaItem.manga).withFadeTransaction()) + router.pushController(AnimeController(mangaItem.manga).withFadeTransaction()) } companion object { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSearchController.kt new file mode 100644 index 000000000..ffaa9329f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSearchController.kt @@ -0,0 +1,131 @@ +package eu.kanade.tachiyomi.ui.browse.migration.search + +import android.app.Dialog +import android.os.Bundle +import androidx.core.view.isVisible +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItemsMultiChoice +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.ui.base.controller.DialogController +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalAnimeSearchController +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalAnimeSearchPresenter +import uy.kohesive.injekt.injectLazy + +class AnimeSearchController( + private var anime: Anime? = null +) : GlobalAnimeSearchController(anime?.title) { + + private var newAnime: Anime? = null + + override fun createPresenter(): GlobalAnimeSearchPresenter { + return AnimeSearchPresenter( + initialQuery, + anime!! + ) + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putSerializable(::anime.name, anime) + outState.putSerializable(::newAnime.name, newAnime) + super.onSaveInstanceState(outState) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + anime = savedInstanceState.getSerializable(::anime.name) as? Anime + newAnime = savedInstanceState.getSerializable(::newAnime.name) as? Anime + } + + fun migrateAnime(anime: Anime? = null, newAnime: Anime?) { + anime ?: return + newAnime ?: return + + (presenter as? AnimeSearchPresenter)?.migrateAnime(anime, newAnime, true) + } + + fun copyAnime(anime: Anime? = null, newAnime: Anime?) { + anime ?: return + newAnime ?: return + + (presenter as? AnimeSearchPresenter)?.migrateAnime(anime, newAnime, false) + } + + override fun onAnimeClick(anime: Anime) { + newAnime = anime + val dialog = + MigrationDialog(this.anime, newAnime, this) + dialog.targetController = this + dialog.showDialog(router) + } + + override fun onAnimeLongClick(anime: Anime) { + // Call parent's default click listener + super.onAnimeClick(anime) + } + + fun renderIsReplacingAnime(isReplacingAnime: Boolean) { + if (isReplacingAnime) { + binding.progress.isVisible = true + } else { + binding.progress.isVisible = false + router.popController(this) + } + } + + class MigrationDialog(private val anime: Anime? = null, private val newAnime: Anime? = null, private val callingController: Controller? = null) : DialogController() { + + private val preferences: PreferencesHelper by injectLazy() + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val prefValue = preferences.migrateFlags().get() + + val preselected = + MigrationFlags.getEnabledFlagsPositions( + prefValue + ) + + return MaterialDialog(activity!!) + .title(R.string.migration_dialog_what_to_include) + .listItemsMultiChoice( + items = MigrationFlags.titles.map { resources?.getString(it) as CharSequence }, + initialSelection = preselected.toIntArray() + ) { _, positions, _ -> + // Save current settings for the next time + val newValue = + MigrationFlags.getFlagsFromPositions( + positions.toTypedArray() + ) + preferences.migrateFlags().set(newValue) + } + .positiveButton(R.string.migrate) { + if (callingController != null) { + if (callingController.javaClass == AnimeSourceSearchController::class.java) { + router.popController(callingController) + } + } + (targetController as? AnimeSearchController)?.migrateAnime(anime, newAnime) + } + .negativeButton(R.string.copy) { + if (callingController != null) { + if (callingController.javaClass == AnimeSourceSearchController::class.java) { + router.popController(callingController) + } + } + (targetController as? AnimeSearchController)?.copyAnime(anime, newAnime) + } + .neutralButton(android.R.string.cancel) + } + } + + override fun onTitleClick(source: CatalogueSource) { + presenter.preferences.lastUsedSource().set(source.id) + + router.pushController(AnimeSourceSearchController(anime, source, presenter.query).withFadeTransaction()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSearchPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSearchPresenter.kt new file mode 100644 index 000000000..c6d2c1774 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSearchPresenter.kt @@ -0,0 +1,169 @@ +package eu.kanade.tachiyomi.ui.browse.migration.search + +import android.os.Bundle +import com.jakewharton.rxrelay.BehaviorRelay +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.data.database.models.AnimeCategory +import eu.kanade.tachiyomi.data.database.models.toAnimeInfo +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.model.SEpisode +import eu.kanade.tachiyomi.source.model.SAnime +import eu.kanade.tachiyomi.source.model.toSEpisode +import eu.kanade.tachiyomi.ui.browse.migration.AnimeMigrationFlags +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalAnimeSearchCardItem +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalAnimeSearchItem +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalAnimeSearchPresenter +import eu.kanade.tachiyomi.util.episode.syncEpisodesWithSource +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.lang.launchUI +import eu.kanade.tachiyomi.util.lang.withUIContext +import eu.kanade.tachiyomi.util.system.toast +import java.util.Date + +class AnimeSearchPresenter( + initialQuery: String? = "", + private val anime: Anime +) : GlobalAnimeSearchPresenter(initialQuery) { + + private val replacingAnimeRelay = BehaviorRelay.create() + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + + replacingAnimeRelay.subscribeLatestCache({ controller, isReplacingAnime -> (controller as? AnimeSearchController)?.renderIsReplacingAnime(isReplacingAnime) }) + } + + override fun getEnabledSources(): List { + // Put the source of the selected anime at the top + return super.getEnabledSources() + .sortedByDescending { it.id == anime.source } + } + + override fun createCatalogueSearchItem(source: CatalogueSource, results: List?): GlobalAnimeSearchItem { + // Set the catalogue search item as highlighted if the source matches that of the selected anime + return GlobalAnimeSearchItem(source, results, source.id == anime.source) + } + + override fun networkToLocalAnime(sAnime: SAnime, sourceId: Long): Anime { + val localAnime = super.networkToLocalAnime(sAnime, sourceId) + // For migration, displayed title should always match source rather than local DB + localAnime.title = sAnime.title + return localAnime + } + + fun migrateAnime(prevAnime: Anime, anime: Anime, replace: Boolean) { + val source = sourceManager.get(anime.source) ?: return + + replacingAnimeRelay.call(true) + + presenterScope.launchIO { + try { + val episodes = source.getEpisodeList(anime.toAnimeInfo()) + .map { it.toSEpisode() } + + migrateAnimeInternal(source, episodes, prevAnime, anime, replace) + } catch (e: Throwable) { + withUIContext { view?.applicationContext?.toast(e.message) } + } + + presenterScope.launchUI { replacingAnimeRelay.call(false) } + } + } + + private fun migrateAnimeInternal( + source: Source, + sourceEpisodes: List, + prevAnime: Anime, + anime: Anime, + replace: Boolean + ) { + val flags = preferences.migrateFlags().get() + val migrateEpisodes = + AnimeMigrationFlags.hasEpisodes( + flags + ) + val migrateCategories = + AnimeMigrationFlags.hasCategories( + flags + ) + val migrateTracks = + AnimeMigrationFlags.hasTracks( + flags + ) + + db.inTransaction { + // Update episodes read + if (migrateEpisodes) { + try { + syncEpisodesWithSource(db, sourceEpisodes, anime, source) + } catch (e: Exception) { + // Worst case, episodes won't be synced + } + + val prevAnimeEpisodes = db.getEpisodes(prevAnime).executeAsBlocking() + val maxEpisodeRead = prevAnimeEpisodes + .filter { it.read } + .maxOfOrNull { it.episode_number } + if (maxEpisodeRead != null) { + val dbEpisodes = db.getEpisodes(anime).executeAsBlocking() + for (episode in dbEpisodes) { + if (episode.isRecognizedNumber) { + val prevEpisode = prevAnimeEpisodes + .find { it.isRecognizedNumber && it.episode_number == episode.episode_number } + if (prevEpisode != null) { + episode.date_fetch = prevEpisode.date_fetch + episode.bookmark = prevEpisode.bookmark + } else if (episode.episode_number <= maxEpisodeRead) { + episode.read = true + } + } + } + db.insertEpisodes(dbEpisodes).executeAsBlocking() + } + } + + // Update categories + if (migrateCategories) { + val categories = db.getCategoriesForAnime(prevAnime).executeAsBlocking() + val animeCategories = categories.map { AnimeCategory.create(anime, it) } + db.setAnimeCategories(animeCategories, listOf(anime)) + } + + // Update track + if (migrateTracks) { + val tracks = db.getTracks(prevAnime).executeAsBlocking() + for (track in tracks) { + track.id = null + track.anime_id = anime.id!! + } + db.insertTracks(tracks).executeAsBlocking() + } + + // Update favorite status + if (replace) { + prevAnime.favorite = false + db.updateAnimeFavorite(prevAnime).executeAsBlocking() + } + anime.favorite = true + db.updateAnimeFavorite(anime).executeAsBlocking() + + // Update reading preferences + anime.episode_flags = prevAnime.episode_flags + db.updateFlags(anime).executeAsBlocking() + anime.viewer = prevAnime.viewer + db.updateAnimeViewer(anime).executeAsBlocking() + + // Update date added + if (replace) { + anime.date_added = prevAnime.date_added + prevAnime.date_added = 0 + } else { + anime.date_added = Date().time + } + + // SearchPresenter#networkToLocalAnime may have updated the anime title, so ensure db gets updated title + db.updateAnimeTitle(anime).executeAsBlocking() + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSourceSearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSourceSearchController.kt new file mode 100644 index 000000000..2848e3ddf --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/AnimeSourceSearchController.kt @@ -0,0 +1,39 @@ +package eu.kanade.tachiyomi.ui.browse.migration.search + +import android.os.Bundle +import android.view.View +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController +import eu.kanade.tachiyomi.ui.browse.source.browse.AnimeSourceItem + +class AnimeSourceSearchController( + bundle: Bundle +) : BrowseSourceController(bundle) { + + constructor(anime: Anime? = null, source: CatalogueSource, searchQuery: String? = null) : this( + Bundle().apply { + putLong(SOURCE_ID_KEY, source.id) + putSerializable(ANIME_KEY, anime) + if (searchQuery != null) { + putString(SEARCH_QUERY_KEY, searchQuery) + } + } + ) + private var oldAnime: Anime? = args.getSerializable(ANIME_KEY) as Anime? + private var newAnime: Anime? = null + + override fun onItemClick(view: View, position: Int): Boolean { + val item = adapter?.getItem(position) as? AnimeSourceItem ?: return false + newAnime = item.anime + val searchController = router.backstack.findLast { it.controller().javaClass == AnimeSearchController::class.java }?.controller() as AnimeSearchController? + val dialog = + AnimeSearchController.MigrationDialog(oldAnime, newAnime, this) + dialog.targetController = searchController + dialog.showDialog(router) + return true + } + private companion object { + const val ANIME_KEY = "oldAnime" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceComfortableGridHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceComfortableGridHolder.kt new file mode 100644 index 000000000..13103e8e9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceComfortableGridHolder.kt @@ -0,0 +1,56 @@ +package eu.kanade.tachiyomi.ui.browse.source.browse + +import android.view.View +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.glide.toAnimeThumbnail +import eu.kanade.tachiyomi.databinding.AnimeSourceComfortableGridItemBinding +import eu.kanade.tachiyomi.databinding.SourceComfortableGridItemBinding +import eu.kanade.tachiyomi.widget.StateImageViewTarget + +/** + * Class used to hold the displayed data of a anime in the catalogue, like the cover or the title. + * All the elements from the layout file "item_source_grid" are available in this class. + * + * @param view the inflated view for this holder. + * @param adapter the adapter handling this holder. + * @constructor creates a new catalogue holder. + */ +class AnimeSourceComfortableGridHolder(private val view: View, private val adapter: FlexibleAdapter<*>) : + AnimeSourceHolder(view, adapter) { + + override val binding = AnimeSourceComfortableGridItemBinding.bind(view) + + /** + * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this + * holder with the given anime. + * + * @param anime the anime to bind. + */ + override fun onSetValues(anime: Anime) { + // Set anime title + binding.title.text = anime.title + + // Set alpha of thumbnail. + binding.thumbnail.alpha = if (anime.favorite) 0.3f else 1.0f + + setImage(anime) + } + + override fun setImage(anime: Anime) { + // For rounded corners + binding.card.clipToOutline = true + + GlideApp.with(view.context).clear(binding.thumbnail) + if (!anime.thumbnail_url.isNullOrEmpty()) { + GlideApp.with(view.context) + .load(anime.toAnimeThumbnail()) + .diskCacheStrategy(DiskCacheStrategy.DATA) + .centerCrop() + .placeholder(android.R.color.transparent) + .into(StateImageViewTarget(binding.thumbnail, binding.progress)) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceGridHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceGridHolder.kt new file mode 100644 index 000000000..2bc13ac16 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceGridHolder.kt @@ -0,0 +1,56 @@ +package eu.kanade.tachiyomi.ui.browse.source.browse + +import android.view.View +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.glide.toAnimeThumbnail +import eu.kanade.tachiyomi.databinding.AnimeSourceComfortableGridItemBinding +import eu.kanade.tachiyomi.databinding.SourceComfortableGridItemBinding +import eu.kanade.tachiyomi.widget.StateImageViewTarget + +/** + * Class used to hold the displayed data of a anime in the catalogue, like the cover or the title. + * All the elements from the layout file "item_source_grid" are available in this class. + * + * @param view the inflated view for this holder. + * @param adapter the adapter handling this holder. + * @constructor creates a new catalogue holder. + */ +open class AnimeSourceGridHolder(private val view: View, private val adapter: FlexibleAdapter<*>) : + AnimeSourceHolder(view, adapter) { + + override val binding = AnimeSourceComfortableGridItemBinding.bind(view) + + /** + * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this + * holder with the given anime. + * + * @param anime the anime to bind. + */ + override fun onSetValues(anime: Anime) { + // Set anime title + binding.title.text = anime.title + + // Set alpha of thumbnail. + binding.thumbnail.alpha = if (anime.favorite) 0.3f else 1.0f + + setImage(anime) + } + + override fun setImage(anime: Anime) { + // For rounded corners + binding.card.clipToOutline = true + + GlideApp.with(view.context).clear(binding.thumbnail) + if (!anime.thumbnail_url.isNullOrEmpty()) { + GlideApp.with(view.context) + .load(anime.toAnimeThumbnail()) + .diskCacheStrategy(DiskCacheStrategy.DATA) + .centerCrop() + .placeholder(android.R.color.transparent) + .into(StateImageViewTarget(binding.thumbnail, binding.progress)) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceHolder.kt new file mode 100644 index 000000000..7eb7f838c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceHolder.kt @@ -0,0 +1,35 @@ +package eu.kanade.tachiyomi.ui.browse.source.browse + +import android.view.View +import androidx.viewbinding.ViewBinding +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.data.database.models.Anime + +/** + * Generic class used to hold the displayed data of a anime in the catalogue. + * + * @param view the inflated view for this holder. + * @param adapter the adapter handling this holder. + */ +abstract class AnimeSourceHolder(view: View, adapter: FlexibleAdapter<*>) : + FlexibleViewHolder(view, adapter) { + + abstract val binding: VB + + /** + * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this + * holder with the given anime. + * + * @param anime the anime to bind. + */ + abstract fun onSetValues(anime: Anime) + + /** + * Updates the image for this holder. Useful to update the image when the anime is initialized + * and the url is now known. + * + * @param anime the anime to bind. + */ + abstract fun setImage(anime: Anime) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceItem.kt new file mode 100644 index 000000000..d78a10aad --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceItem.kt @@ -0,0 +1,91 @@ +package eu.kanade.tachiyomi.ui.browse.source.browse + +import android.view.Gravity +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.FrameLayout +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.RecyclerView +import com.tfcporciuncula.flow.Preference +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.Anime +import eu.kanade.tachiyomi.data.preference.PreferenceValues.DisplayMode +import eu.kanade.tachiyomi.databinding.AnimeSourceComfortableGridItemBinding +import eu.kanade.tachiyomi.databinding.AnimeSourceCompactGridItemBinding +import eu.kanade.tachiyomi.widget.AutofitRecyclerView + +class AnimeSourceItem(val anime: Anime, private val displayMode: Preference) : + AbstractFlexibleItem>() { + + override fun getLayoutRes(): Int { + return when (displayMode.get()) { + DisplayMode.COMPACT_GRID -> R.layout.source_compact_grid_item + DisplayMode.COMFORTABLE_GRID -> R.layout.source_comfortable_grid_item + DisplayMode.LIST -> R.layout.source_list_item + } + } + + override fun createViewHolder( + view: View, + adapter: FlexibleAdapter> + ): AnimeSourceHolder<*> { + return when (displayMode.get()) { + DisplayMode.COMPACT_GRID -> { + val binding = AnimeSourceCompactGridItemBinding.bind(view) + val parent = adapter.recyclerView as AutofitRecyclerView + val coverHeight = parent.itemWidth / 3 * 4 + view.apply { + binding.card.layoutParams = FrameLayout.LayoutParams( + MATCH_PARENT, + coverHeight + ) + binding.gradient.layoutParams = FrameLayout.LayoutParams( + MATCH_PARENT, + coverHeight / 2, + Gravity.BOTTOM + ) + } + AnimeSourceGridHolder(view, adapter) + } + DisplayMode.COMFORTABLE_GRID -> { + val binding = AnimeSourceComfortableGridItemBinding.bind(view) + val parent = adapter.recyclerView as AutofitRecyclerView + val coverHeight = parent.itemWidth / 3 * 4 + view.apply { + binding.card.layoutParams = ConstraintLayout.LayoutParams( + MATCH_PARENT, + coverHeight + ) + } + AnimeSourceComfortableGridHolder(view, adapter) + } + DisplayMode.LIST -> { + AnimeSourceListHolder(view, adapter) + } + } + } + + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: AnimeSourceHolder<*>, + position: Int, + payloads: List? + ) { + holder.onSetValues(anime) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is AnimeSourceItem) { + return anime.id!! == other.anime.id!! + } + return false + } + + override fun hashCode(): Int { + return anime.id!!.hashCode() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceListHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceListHolder.kt new file mode 100644 index 000000000..eb89e68a2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/AnimeSourceListHolder.kt @@ -0,0 +1,64 @@ +package eu.kanade.tachiyomi.ui.browse.source.browse + +import android.view.View +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.request.RequestOptions +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.glide.toAnimeThumbnail +import eu.kanade.tachiyomi.databinding.AnimeSourceListItemBinding +import eu.kanade.tachiyomi.databinding.SourceListItemBinding +import eu.kanade.tachiyomi.util.system.getResourceColor + +/** + * Class used to hold the displayed data of a anime in the catalogue, like the cover or the title. + * All the elements from the layout file "item_catalogue_list" are available in this class. + * + * @param view the inflated view for this holder. + * @param adapter the adapter handling this holder. + * @constructor creates a new catalogue holder. + */ +class AnimeSourceListHolder(private val view: View, adapter: FlexibleAdapter<*>) : + AnimeSourceHolder(view, adapter) { + + override val binding = AnimeSourceListItemBinding.bind(view) + + private val favoriteColor = view.context.getResourceColor(R.attr.colorOnSurface, 0.38f) + private val unfavoriteColor = view.context.getResourceColor(R.attr.colorOnSurface) + + /** + * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this + * holder with the given anime. + * + * @param anime the anime to bind. + */ + override fun onSetValues(anime: Anime) { + binding.title.text = anime.title + binding.title.setTextColor(if (anime.favorite) favoriteColor else unfavoriteColor) + + // Set alpha of thumbnail. + binding.thumbnail.alpha = if (anime.favorite) 0.3f else 1.0f + + setImage(anime) + } + + override fun setImage(anime: Anime) { + GlideApp.with(view.context).clear(binding.thumbnail) + + if (!anime.thumbnail_url.isNullOrEmpty()) { + val radius = view.context.resources.getDimensionPixelSize(R.dimen.card_radius) + val requestOptions = RequestOptions().transform(CenterCrop(), RoundedCorners(radius)) + GlideApp.with(view.context) + .load(anime.toAnimeThumbnail()) + .diskCacheStrategy(DiskCacheStrategy.DATA) + .apply(requestOptions) + .dontAnimate() + .placeholder(android.R.color.transparent) + .into(binding.thumbnail) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchAdapter.kt new file mode 100644 index 000000000..15feb4d56 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchAdapter.kt @@ -0,0 +1,81 @@ +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 + +/** + * Adapter that holds the search cards. + * + * @param controller instance of [GlobalSearchController]. + */ +class GlobalAnimeSearchAdapter(val controller: GlobalAnimeSearchController) : + 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/source/globalsearch/GlobalAnimeSearchCardAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchCardAdapter.kt new file mode 100644 index 000000000..a61000bfd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchCardAdapter.kt @@ -0,0 +1,27 @@ +package eu.kanade.tachiyomi.ui.browse.source.globalsearch + +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.data.database.models.Anime + +/** + * Adapter that holds the anime items from search results. + * + * @param controller instance of [GlobalSearchController]. + */ +class GlobalAnimeSearchCardAdapter(controller: GlobalAnimeSearchController) : + FlexibleAdapter(null, controller, true) { + + /** + * Listen for browse item clicks. + */ + val animeClickListener: OnAnimeClickListener = controller + + /** + * Listener which should be called when user clicks browse. + * Note: Should only be handled by [GlobalSearchController] + */ + interface OnAnimeClickListener { + fun onAnimeClick(anime: Anime) + fun onAnimeLongClick(anime: Anime) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchCardHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchCardHolder.kt new file mode 100644 index 000000000..ee5e4ffc6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchCardHolder.kt @@ -0,0 +1,56 @@ +package eu.kanade.tachiyomi.ui.browse.source.globalsearch + +import android.view.View +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.glide.toAnimeThumbnail +import eu.kanade.tachiyomi.databinding.GlobalSearchControllerCardItemBinding +import eu.kanade.tachiyomi.widget.StateImageViewTarget + +class GlobalAnimeSearchCardHolder(view: View, adapter: GlobalAnimeSearchCardAdapter) : + FlexibleViewHolder(view, adapter) { + + private val binding = GlobalSearchControllerCardItemBinding.bind(view) + + init { + // Call onAnimeClickListener when item is pressed. + itemView.setOnClickListener { + val item = adapter.getItem(bindingAdapterPosition) + if (item != null) { + adapter.animeClickListener.onAnimeClick(item.anime) + } + } + itemView.setOnLongClickListener { + val item = adapter.getItem(bindingAdapterPosition) + if (item != null) { + adapter.animeClickListener.onAnimeLongClick(item.anime) + } + true + } + } + + fun bind(anime: Anime) { + binding.card.clipToOutline = true + + binding.title.text = anime.title + // Set alpha of thumbnail. + binding.cover.alpha = if (anime.favorite) 0.3f else 1.0f + + setImage(anime) + } + + fun setImage(anime: Anime) { + GlideApp.with(itemView.context).clear(binding.cover) + if (!anime.thumbnail_url.isNullOrEmpty()) { + GlideApp.with(itemView.context) + .load(anime.toAnimeThumbnail()) + .diskCacheStrategy(DiskCacheStrategy.DATA) + .centerCrop() + .skipMemoryCache(true) + .placeholder(android.R.color.transparent) + .into(StateImageViewTarget(binding.cover, binding.progress)) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchCardItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchCardItem.kt new file mode 100644 index 000000000..ec00683d3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchCardItem.kt @@ -0,0 +1,40 @@ +package eu.kanade.tachiyomi.ui.browse.source.globalsearch + +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.Anime + +class GlobalAnimeSearchCardItem(val anime: Anime) : AbstractFlexibleItem() { + + override fun getLayoutRes(): Int { + return R.layout.global_search_controller_card_item + } + + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): GlobalAnimeSearchCardHolder { + return GlobalAnimeSearchCardHolder(view, adapter as GlobalAnimeSearchCardAdapter) + } + + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: GlobalAnimeSearchCardHolder, + position: Int, + payloads: List? + ) { + holder.bind(anime) + } + + override fun equals(other: Any?): Boolean { + if (other is GlobalAnimeSearchCardItem) { + return anime.id == other.anime.id + } + return false + } + + override fun hashCode(): Int { + return anime.id?.toInt() ?: 0 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchController.kt new file mode 100644 index 000000000..452f4130d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchController.kt @@ -0,0 +1,226 @@ +package eu.kanade.tachiyomi.ui.browse.source.globalsearch + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.SearchView +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import dev.chrisbanes.insetter.applyInsetter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.databinding.GlobalAnimeSearchControllerBinding +import eu.kanade.tachiyomi.databinding.GlobalSearchControllerBinding +import eu.kanade.tachiyomi.source.CatalogueSource +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.anime.AnimeController +import uy.kohesive.injekt.injectLazy + +/** + * 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 [GlobalSearchPresenter] + * [GlobalAnimeSearchCardAdapter.OnAnimeClickListener] called when anime is clicked in global search + */ +open class GlobalAnimeSearchController( + protected val initialQuery: String? = null, + protected val extensionFilter: String? = null +) : SearchableNucleusController(), + GlobalAnimeSearchCardAdapter.OnAnimeClickListener, + GlobalAnimeSearchAdapter.OnTitleClickListener { + + private val preferences: PreferencesHelper by injectLazy() + + /** + * Adapter containing search results grouped by lang. + */ + protected var adapter: GlobalAnimeSearchAdapter? = null + + /** + * Ref to the OptionsMenu.SearchItem created in onCreateOptionsMenu + */ + private var optionsMenuSearchItem: MenuItem? = 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 = GlobalAnimeSearchControllerBinding.inflate(inflater) + binding.recycler.applyInsetter { + type(navigationBars = true) { + padding() + } + } + return binding.root + } + + override fun getTitle(): String? { + return presenter.query + } + + /** + * Create the [GlobalSearchPresenter] used in controller. + * + * @return instance of [GlobalSearchPresenter] + */ + override fun createPresenter(): GlobalAnimeSearchPresenter { + return GlobalAnimeSearchPresenter(initialQuery, extensionFilter) + } + + /** + * Called when anime in global search is clicked, opens anime. + * + * @param anime clicked item containing anime information. + */ + override fun onAnimeClick(anime: Anime) { + router.pushController(AnimeController(anime, true).withFadeTransaction()) + } + + /** + * Called when anime in global search is long clicked. + * + * @param anime clicked item containing anime information. + */ + override fun onAnimeLongClick(anime: Anime) { + // Delegate to single click by default. + onAnimeClick(anime) + } + + /** + * 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, + null, + false // the onMenuItemActionExpand will handle this + ) + + optionsMenuSearchItem = menu.findItem(R.id.action_search) + } + + override fun onSearchMenuItemActionExpand(item: MenuItem?) { + super.onSearchMenuItemActionExpand(item) + val searchView = optionsMenuSearchItem?.actionView as SearchView + searchView.onActionViewExpanded() // Required to show the query in the view + + if (nonSubmittedQuery.isBlank()) { + searchView.setQuery(presenter.query, false) + } + } + + override fun onSearchViewQueryTextSubmit(query: String?) { + presenter.search(query ?: "") + optionsMenuSearchItem?.collapseActionView() + setTitle() // Update toolbar title + } + + /** + * Called when the view is created + * + * @param view view of controller + */ + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + adapter = GlobalAnimeSearchAdapter(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) + } + + /** + * Returns the view holder for the given anime. + * + * @param source used to find holder containing source + * @return the holder of the anime or null if it's not bound. + */ + private fun getHolder(source: CatalogueSource): GlobalAnimeSearchHolder? { + 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 GlobalAnimeSearchHolder + } + } + + return null + } + + /** + * Add search result to adapter. + * + * @param searchResult result of search. + */ + fun setItems(searchResult: List) { + if (searchResult.isEmpty() && preferences.searchPinnedSourcesOnly()) { + binding.emptyView.show(R.string.no_pinned_sources) + } else { + binding.emptyView.hide() + } + + adapter?.updateDataSet(searchResult) + + val progress = searchResult.mapNotNull { it.results }.size.toDouble() / searchResult.size + if (progress < 1) { + binding.progressBar.isVisible = true + binding.progressBar.progress = (progress * 100).toInt() + } else { + binding.progressBar.isVisible = false + } + } + + /** + * Called from the presenter when a anime is initialized. + * + * @param anime the initialized anime. + */ + fun onAnimeInitialized(source: CatalogueSource, anime: Anime) { + getHolder(source)?.setImage(anime) + } + + /** + * Opens a catalogue with the given search. + */ + override fun onTitleClick(source: CatalogueSource) { + presenter.preferences.lastUsedSource().set(source.id) + router.pushController(BrowseSourceController(source, presenter.query).withFadeTransaction()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchHolder.kt new file mode 100644 index 000000000..feb823a4f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchHolder.kt @@ -0,0 +1,110 @@ +package eu.kanade.tachiyomi.ui.browse.source.globalsearch + +import android.view.View +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.databinding.GlobalSearchControllerCardBinding +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.util.system.LocaleHelper + +/** + * Holder that binds the [GlobalSearchItem] containing catalogue cards. + * + * @param view view of [GlobalSearchItem] + * @param adapter instance of [GlobalSearchAdapter] + */ +class GlobalAnimeSearchHolder(view: View, val adapter: GlobalAnimeSearchAdapter) : + FlexibleViewHolder(view, adapter) { + + private val binding = GlobalSearchControllerCardBinding.bind(view) + + /** + * Adapter containing anime from search results. + */ + private val animeAdapter = GlobalAnimeSearchCardAdapter(adapter.controller) + + private var lastBoundResults: List? = null + + init { + // Set layout horizontal. + binding.recycler.layoutManager = LinearLayoutManager(view.context, LinearLayoutManager.HORIZONTAL, false) + binding.recycler.adapter = animeAdapter + + binding.titleWrapper.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: GlobalAnimeSearchItem) { + val source = item.source + val results = item.results + + val titlePrefix = if (item.highlighted) "▶ " else "" + + binding.title.text = titlePrefix + source.name + binding.subtitle.isVisible = source !is LocalSource + binding.subtitle.text = LocaleHelper.getDisplayName(source.lang) + + when { + results == null -> { + binding.progress.isVisible = true + showResultsHolder() + } + results.isEmpty() -> { + binding.progress.isVisible = false + showNoResults() + } + else -> { + binding.progress.isVisible = false + showResultsHolder() + } + } + if (results !== lastBoundResults) { + animeAdapter.updateDataSet(results) + lastBoundResults = results + } + } + + /** + * Called from the presenter when a anime is initialized. + * + * @param anime the initialized anime. + */ + fun setImage(anime: Anime) { + getHolder(anime)?.setImage(anime) + } + + /** + * Returns the view holder for the given anime. + * + * @param anime the anime to find. + * @return the holder of the anime or null if it's not bound. + */ + private fun getHolder(anime: Anime): GlobalAnimeSearchCardHolder? { + animeAdapter.allBoundViewHolders.forEach { holder -> + val item = animeAdapter.getItem(holder.bindingAdapterPosition) + if (item != null && item.anime.id!! == anime.id!!) { + return holder as GlobalAnimeSearchCardHolder + } + } + + return null + } + + private fun showResultsHolder() { + binding.noResultsFound.isVisible = false + } + + private fun showNoResults() { + binding.noResultsFound.isVisible = true + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchItem.kt new file mode 100644 index 000000000..9836cca4f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchItem.kt @@ -0,0 +1,71 @@ +package eu.kanade.tachiyomi.ui.browse.source.globalsearch + +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 + +/** + * 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 GlobalAnimeSearchItem(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 [GlobalSearchAdapter]. + * + * @return holder of view. + */ + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): GlobalAnimeSearchHolder { + return GlobalAnimeSearchHolder(view, adapter as GlobalAnimeSearchAdapter) + } + + /** + * Bind item to view. + */ + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: GlobalAnimeSearchHolder, + 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 GlobalAnimeSearchItem) { + 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/source/globalsearch/GlobalAnimeSearchPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchPresenter.kt new file mode 100644 index 000000000..31d366874 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalAnimeSearchPresenter.kt @@ -0,0 +1,273 @@ +package eu.kanade.tachiyomi.ui.browse.source.globalsearch + +import android.os.Bundle +import eu.kanade.tachiyomi.data.database.AnimeDatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.data.database.models.toAnimeInfo +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.AnimesPage +import eu.kanade.tachiyomi.source.model.SAnime +import eu.kanade.tachiyomi.source.model.toSAnime +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter +import eu.kanade.tachiyomi.util.lang.runAsObservable +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 +import uy.kohesive.injekt.injectLazy + +/** + * Presenter of [GlobalAnimeSearchController] + * 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 GlobalAnimeSearchPresenter( + val initialQuery: String? = "", + val initialExtensionFilter: String? = null, + val sourceManager: SourceManager = Injekt.get(), + val db: AnimeDatabaseHelper = Injekt.get(), + val preferences: PreferencesHelper = Injekt.get() +) : BasePresenter() { + + /** + * Enabled sources. + */ + val sources by lazy { getSourcesToQuery() } + + /** + * Fetches the different sources by user settings. + */ + private var fetchSourcesSubscription: Subscription? = null + + /** + * Subject which fetches image of given anime. + */ + private val fetchImageSubject = PublishSubject.create, Source>>() + + /** + * Subscription for fetching images of anime. + */ + private var fetchImageSubscription: Subscription? = null + + private val extensionManager: ExtensionManager by injectLazy() + + private var extensionFilter: String? = null + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + + extensionFilter = savedState?.getString(GlobalAnimeSearchPresenter::extensionFilter.name) + ?: initialExtensionFilter + + // Perform a search with previous or initial state + search( + savedState?.getString(BrowseSourcePresenter::query.name) + ?: initialQuery.orEmpty() + ) + } + + override fun onDestroy() { + fetchSourcesSubscription?.unsubscribe() + fetchImageSubscription?.unsubscribe() + super.onDestroy() + } + + override fun onSave(state: Bundle) { + state.putString(BrowseSourcePresenter::query.name, query) + state.putString(GlobalAnimeSearchPresenter::extensionFilter.name, extensionFilter) + super.onSave(state) + } + + /** + * 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 disabledSourceIds = preferences.disabledSources().get() + val pinnedSourceIds = preferences.pinnedSources().get() + + return sourceManager.getCatalogueSources() + .filter { it.lang in languages } + .filterNot { it.id.toString() in disabledSourceIds } + .sortedWith(compareBy({ it.id.toString() !in pinnedSourceIds }, { "${it.name.toLowerCase()} (${it.lang})" })) + } + + private fun getSourcesToQuery(): List { + val filter = extensionFilter + val enabledSources = getEnabledSources() + var filteredSources: List? = null + + if (!filter.isNullOrEmpty()) { + filteredSources = extensionManager.installedExtensions + .filter { it.pkgName == filter } + .flatMap { it.sources } + .filter { it in enabledSources } + .filterIsInstance() + } + + if (filteredSources != null && filteredSources.isNotEmpty()) { + return filteredSources + } + + val onlyPinnedSources = preferences.searchPinnedSourcesOnly() + val pinnedSourceIds = preferences.pinnedSources().get() + + return enabledSources + .filter { if (onlyPinnedSources) it.id.toString() in pinnedSourceIds else true } + } + + /** + * Creates a catalogue search item + */ + protected open fun createCatalogueSearchItem(source: CatalogueSource, results: List?): GlobalAnimeSearchItem { + return GlobalAnimeSearchItem(source, results) + } + + /** + * Initiates a search for anime per catalogue. + * + * @param query query on which to search. + */ + fun search(query: String) { + // Return if there's nothing to do + if (this.query == query) return + + // Update query + this.query = query + + // Create image fetch subscription + initializeFetchImageSubscription() + + // Create items with the initial state + val initialItems = sources.map { createCatalogueSearchItem(it, null) } + var items = initialItems + + val pinnedSourceIds = preferences.pinnedSources().get() + + fetchSourcesSubscription?.unsubscribe() + fetchSourcesSubscription = Observable.from(sources) + .flatMap( + { source -> + Observable.defer { source.fetchSearchAnime(1, query, FilterList()) } + .subscribeOn(Schedulers.io()) + .onErrorReturn { AnimesPage(emptyList(), false) } // Ignore timeouts or other exceptions + .map { it.animes } + .map { list -> list.map { networkToLocalAnime(it, source.id) } } // Convert to local anime + .doOnNext { fetchImage(it, source) } // Load anime covers + .map { list -> createCatalogueSearchItem(source, list.map { GlobalAnimeSearchCardItem(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 } + .sortedWith( + compareBy( + // Bubble up sources that actually have results + { it.results.isNullOrEmpty() }, + // Same as initial sort, i.e. pinned first then alphabetically + { it.source.id.toString() !in pinnedSourceIds }, + { "${it.source.name.toLowerCase()} (${it.source.lang})" } + ) + ) + } + // Update current state + .doOnNext { items = it } + // Deliver initial state + .startWith(initialItems) + .subscribeLatestCache( + { view, anime -> + view.setItems(anime) + }, + { _, error -> + Timber.e(error) + } + ) + } + + /** + * Initialize a list of anime. + * + * @param anime the list of anime to initialize. + */ + private fun fetchImage(anime: List, source: Source) { + fetchImageSubject.onNext(Pair(anime, source)) + } + + /** + * Subscribes to the initializer of anime details and updates the view if needed. + */ + private fun initializeFetchImageSubscription() { + fetchImageSubscription?.unsubscribe() + fetchImageSubscription = fetchImageSubject.observeOn(Schedulers.io()) + .flatMap { (first, source) -> + Observable.from(first) + .filter { it.thumbnail_url == null && !it.initialized } + .map { Pair(it, source) } + .concatMap { runAsObservable({ getAnimeDetails(it.first, it.second) }) } + .map { Pair(source as CatalogueSource, it) } + } + .onBackpressureBuffer() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { (source, anime) -> + @Suppress("DEPRECATION") + view?.onAnimeInitialized(source, anime) + }, + { error -> + Timber.e(error) + } + ) + } + + /** + * Initializes the given anime. + * + * @param anime the anime to initialize. + * @return The initialized anime. + */ + private suspend fun getAnimeDetails(anime: Anime, source: Source): Anime { + val networkAnime = source.getAnimeDetails(anime.toAnimeInfo()) + anime.copyFrom(networkAnime.toSAnime()) + anime.initialized = true + db.insertAnime(anime).executeAsBlocking() + return anime + } + + /** + * Returns a anime from the database for the given anime from network. It creates a new entry + * if the anime is not yet in the database. + * + * @param sAnime the anime from the source. + * @return a anime from the database. + */ + protected open fun networkToLocalAnime(sAnime: SAnime, sourceId: Long): Anime { + var localAnime = db.getAnime(sAnime.url, sourceId).executeAsBlocking() + if (localAnime == null) { + val newAnime = Anime.create(sAnime.url, sAnime.title, sourceId) + newAnime.copyFrom(sAnime) + val result = db.insertAnime(newAnime).executeAsBlocking() + newAnime.id = result.insertedId() + localAnime = newAnime + } + return localAnime + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt index e5b7b664d..b6321af10 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt @@ -32,7 +32,7 @@ import eu.kanade.tachiyomi.ui.base.controller.TabbedController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController import eu.kanade.tachiyomi.ui.main.MainActivity -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.AnimeController import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.flow.drop @@ -417,7 +417,7 @@ class LibraryController( when (item.itemId) { R.id.action_search -> expandActionViewFromInteraction = true R.id.action_filter -> showSettingsSheet() - R.id.action_update_library -> { + R.id.action_update_animelib -> { activity?.let { if (LibraryUpdateService.start(it)) { it.toast(R.string.updating_library) @@ -487,7 +487,7 @@ class LibraryController( // Notify the presenter a manga is being opened. presenter.onOpenManga() - router.pushController(MangaController(manga).withFadeTransaction()) + router.pushController(AnimeController(manga).withFadeTransaction()) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 04c402583..fed43212a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -32,7 +32,6 @@ import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.preference.asImmediateFlow import eu.kanade.tachiyomi.databinding.MainActivityBinding import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi -import eu.kanade.tachiyomi.ui.animelib.MoreController import eu.kanade.tachiyomi.ui.base.activity.BaseViewBindingActivity import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.controller.FabController @@ -45,7 +44,7 @@ import eu.kanade.tachiyomi.ui.browse.BrowseController import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController import eu.kanade.tachiyomi.ui.download.DownloadController import eu.kanade.tachiyomi.ui.library.LibraryController -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.AnimeController import eu.kanade.tachiyomi.ui.more.MoreController import eu.kanade.tachiyomi.ui.recent.history.HistoryController import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController @@ -69,7 +68,7 @@ class MainActivity : BaseViewBindingActivity() { 2 -> R.id.nav_history 3 -> R.id.nav_updates 4 -> R.id.nav_browse - 5 -> R.id.nav_animelib + 5 -> R.id.nav_library else -> R.id.nav_library } } @@ -148,7 +147,7 @@ class MainActivity : BaseViewBindingActivity() { R.id.nav_history -> setRoot(HistoryController(), id) R.id.nav_browse -> setRoot(BrowseController(), id) R.id.nav_more -> setRoot(MoreController(), id) - R.id.nav_animelib -> setRoot(AnimelibController(), id) + R.id.nav_library -> setRoot(AnimelibController(), id) } } else if (!isHandlingShortcut) { when (id) { @@ -284,7 +283,7 @@ class MainActivity : BaseViewBindingActivity() { router.popToRoot() } setSelectedNavItem(R.id.nav_library) - router.pushController(RouterTransaction.with(MangaController(extras))) + router.pushController(RouterTransaction.with(AnimeController(extras))) } SHORTCUT_DOWNLOADS -> { if (router.backstackSize > 1) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt index ee3e6ddb3..651b28d27 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt @@ -19,7 +19,7 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.AnimeController import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.view.setChips @@ -33,10 +33,10 @@ import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy class MangaInfoHeaderAdapter( - private val controller: MangaController, - private val fromSource: Boolean + private val controller: AnimeController, + private val fromSource: Boolean ) : - RecyclerView.Adapter() { + RecyclerView.Adapter() { private val trackManager: TrackManager by injectLazy() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt index 57e2ad3ab..769b82fac 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt @@ -24,7 +24,7 @@ import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.browse.source.browse.ProgressItem -import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.AnimeController import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.flow.filter @@ -171,7 +171,7 @@ class HistoryController : override fun onItemClick(position: Int) { val manga = (adapter?.getItem(position) as? HistoryItem)?.mch?.manga ?: return - router.pushController(MangaController(manga).withFadeTransaction()) + router.pushController(AnimeController(manga).withFadeTransaction()) } override fun removeHistory(manga: Manga, history: History, all: Boolean) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesAdapter.kt index 78a5b8e65..972fc7eb5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesAdapter.kt @@ -3,13 +3,13 @@ package eu.kanade.tachiyomi.ui.recent.updates import android.content.Context import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChaptersAdapter +import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseEpisodesAdapter import eu.kanade.tachiyomi.util.system.getResourceColor class UpdatesAdapter( val controller: UpdatesController, context: Context -) : BaseChaptersAdapter>(controller) { +) : BaseEpisodesAdapter>(controller) { var readColor = context.getResourceColor(R.attr.colorOnSurface, 0.38f) var unreadColor = context.getResourceColor(R.attr.colorOnSurface) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt index 9fc491ad3..a8a611642 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt @@ -20,10 +20,9 @@ import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.databinding.UpdatesControllerBinding import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.RootController -import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.main.MainActivity -import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChaptersAdapter +import eu.kanade.tachiyomi.ui.manga.AnimeController +import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseEpisodesAdapter import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.util.system.notificationManager import eu.kanade.tachiyomi.util.system.toast @@ -43,7 +42,7 @@ class UpdatesController : FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemLongClickListener, FlexibleAdapter.OnUpdateListener, - BaseChaptersAdapter.OnChapterClickListener, + BaseEpisodesAdapter.OnChapterClickListener, ConfirmDeleteChaptersDialog.Listener, UpdatesAdapter.OnCoverClickListener { @@ -132,7 +131,7 @@ class UpdatesController : override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { - R.id.action_update_library -> updateLibrary() + R.id.action_update_animelib -> updateLibrary() } return super.onOptionsItemSelected(item) @@ -287,7 +286,7 @@ class UpdatesController : } private fun openManga(chapter: UpdatesItem) { - router.pushController(MangaController(chapter.manga).withFadeTransaction()) + router.pushController(AnimeController(chapter.manga).withFadeTransaction()) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt index e0afdcbdc..536260de6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt @@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.data.glide.GlideApp import eu.kanade.tachiyomi.data.glide.toMangaThumbnail import eu.kanade.tachiyomi.databinding.UpdatesItemBinding import eu.kanade.tachiyomi.source.LocalSource -import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterHolder +import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseEpisodeHolder /** * Holder that contains chapter item @@ -23,7 +23,7 @@ import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterHolder * @constructor creates a new recent chapter holder. */ class UpdatesHolder(private val view: View, private val adapter: UpdatesAdapter) : - BaseChapterHolder(view, adapter) { + BaseEpisodeHolder(view, adapter) { private val binding = UpdatesItemBinding.bind(view) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesItem.kt index 1f25244b5..d43169f69 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesItem.kt @@ -7,11 +7,11 @@ import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterItem +import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseEpisodeItem import eu.kanade.tachiyomi.ui.recent.DateSectionItem class UpdatesItem(chapter: Chapter, val manga: Manga, header: DateSectionItem) : - BaseChapterItem(chapter, header) { + BaseEpisodeItem(chapter, header) { override fun getLayoutRes(): Int { return R.layout.updates_item diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ChapterLoadStrategy.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ChapterLoadStrategy.kt new file mode 100644 index 000000000..7bc91b6ad --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ChapterLoadStrategy.kt @@ -0,0 +1,46 @@ +package eu.kanade.tachiyomi.ui.watcher + +import eu.kanade.tachiyomi.data.database.models.Episode + +/** + * Load strategy using the source order. This is the default ordering. + */ +class EpisodeLoadBySource { + fun get(allEpisodes: List): List { + return allEpisodes.sortedByDescending { it.source_order } + } +} + +/** + * Load strategy using unique episode numbers with same scanlator preference. + */ +class EpisodeLoadByNumber { + fun get(allEpisodes: List, selectedEpisode: Episode): List { + val episodes = mutableListOf() + val episodesByNumber = allEpisodes.groupBy { it.episode_number } + + for ((number, episodesForNumber) in episodesByNumber) { + val preferredEpisode = when { + // Make sure the selected episode is always present + number == selectedEpisode.episode_number -> selectedEpisode + // If there is only one episode for this number, use it + episodesForNumber.size == 1 -> episodesForNumber.first() + // Prefer a episode of the same scanlator as the selected + else -> + episodesForNumber.find { it.scanlator == selectedEpisode.scanlator } + ?: episodesForNumber.first() + } + episodes.add(preferredEpisode) + } + return episodes.sortedBy { it.episode_number } + } +} + +/** + * Load strategy using the episode upload date. This ordering ignores scanlators + */ +class EpisodeLoadByUploadDate { + fun get(allEpisodes: List): List { + return allEpisodes.sortedBy { it.date_upload } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/PageIndicatorTextView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/PageIndicatorTextView.kt new file mode 100644 index 000000000..961e9657c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/PageIndicatorTextView.kt @@ -0,0 +1,54 @@ +package eu.kanade.tachiyomi.ui.watcher + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ScaleXSpan +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import eu.kanade.tachiyomi.widget.OutlineSpan + +/** + * Page indicator found at the bottom of the watcher + */ +class PageIndicatorTextView( + context: Context, + attrs: AttributeSet? = null +) : AppCompatTextView(context, attrs) { + + init { + setTextColor(fillColor) + } + + @SuppressLint("SetTextI18n") + override fun setText(text: CharSequence?, type: BufferType?) { + // Add spaces at the start & end of the text, otherwise the stroke is cut-off because it's + // not taken into account when measuring the text (view's padding doesn't help). + val currText = " $text " + + // Also add a bit of spacing between each character, as the stroke overlaps them + val finalText = SpannableString(currText.asIterable().joinToString("\u00A0")).apply { + // Apply text outline + setSpan(spanOutline, 1, length - 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + + for (i in 1..lastIndex step 2) { + setSpan(ScaleXSpan(0.2f), i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + + super.setText(finalText, BufferType.SPANNABLE) + } + + private companion object { + private val fillColor = Color.rgb(235, 235, 235) + private val strokeColor = Color.rgb(45, 45, 45) + + // A span object with text outlining properties + val spanOutline = OutlineSpan( + strokeColor = strokeColor, + strokeWidth = 4f + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderColorFilterView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderColorFilterView.kt new file mode 100644 index 000000000..c6a064022 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderColorFilterView.kt @@ -0,0 +1,36 @@ +package eu.kanade.tachiyomi.ui.watcher + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.util.AttributeSet +import android.view.View +import androidx.core.graphics.toXfermode + +class WatcherColorFilterView( + context: Context, + attrs: AttributeSet? = null +) : View(context, attrs) { + + private val colorFilterPaint: Paint = Paint() + + fun setFilterColor(color: Int, filterMode: Int) { + colorFilterPaint.color = color + colorFilterPaint.xfermode = when (filterMode) { + 1 -> PorterDuff.Mode.MULTIPLY + 2 -> PorterDuff.Mode.SCREEN + 3 -> PorterDuff.Mode.OVERLAY + 4 -> PorterDuff.Mode.LIGHTEN + 5 -> PorterDuff.Mode.DARKEN + else -> PorterDuff.Mode.SRC_OVER + }.toXfermode() + + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawPaint(colorFilterPaint) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderNavigationOverlayView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderNavigationOverlayView.kt new file mode 100644 index 000000000..f86f4c359 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderNavigationOverlayView.kt @@ -0,0 +1,119 @@ +package eu.kanade.tachiyomi.ui.watcher + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.ViewPropertyAnimator +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import eu.kanade.tachiyomi.ui.watcher.viewer.ViewerNavigation +import kotlin.math.abs + +class WatcherNavigationOverlayView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) { + + private var viewPropertyAnimator: ViewPropertyAnimator? = null + + private var navigation: ViewerNavigation? = null + + fun setNavigation(navigation: ViewerNavigation, showOnStart: Boolean) { + if (!showOnStart && this.navigation == null) { + this.navigation = navigation + isVisible = false + return + } + + this.navigation = navigation + invalidate() + + if (isVisible) return + + viewPropertyAnimator = animate() + .alpha(1f) + .setDuration(FADE_DURATION) + .withStartAction { + isVisible = true + } + .withEndAction { + viewPropertyAnimator = null + } + viewPropertyAnimator?.start() + } + + private val regionPaint = Paint() + + private val textPaint = Paint().apply { + textAlign = Paint.Align.CENTER + color = Color.WHITE + textSize = 64f + } + + private val textBorderPaint = Paint().apply { + textAlign = Paint.Align.CENTER + color = Color.BLACK + textSize = 64f + style = Paint.Style.STROKE + strokeWidth = 8f + } + + override fun onDraw(canvas: Canvas?) { + if (navigation == null) return + + navigation?.regions?.forEach { region -> + val rect = region.rectF + + canvas?.save() + + // Scale rect from 1f,1f to screen width and height + canvas?.scale(width.toFloat(), height.toFloat()) + regionPaint.color = ContextCompat.getColor(context, region.type.colorRes) + canvas?.drawRect(rect, regionPaint) + + canvas?.restore() + // Don't want scale anymore because it messes with drawText + canvas?.save() + + // Translate origin to rect start (left, top) + canvas?.translate((width * rect.left), (height * rect.top)) + + // Calculate center of rect width on screen + val x = width * (abs(rect.left - rect.right) / 2) + + // Calculate center of rect height on screen + val y = height * (abs(rect.top - rect.bottom) / 2) + + canvas?.drawText(context.getString(region.type.nameRes), x, y, textBorderPaint) + canvas?.drawText(context.getString(region.type.nameRes), x, y, textPaint) + + canvas?.restore() + } + } + + override fun performClick(): Boolean { + super.performClick() + + if (viewPropertyAnimator == null && isVisible) { + viewPropertyAnimator = animate() + .alpha(0f) + .setDuration(FADE_DURATION) + .withEndAction { + isVisible = false + viewPropertyAnimator = null + } + viewPropertyAnimator?.start() + } + + return true + } + + override fun onTouchEvent(event: MotionEvent?): Boolean { + // Hide overlay if user start tapping or swiping + performClick() + return super.onTouchEvent(event) + } +} + +private const val FADE_DURATION = 1000L diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderPageSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderPageSheet.kt new file mode 100644 index 000000000..ad4ffc985 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderPageSheet.kt @@ -0,0 +1,59 @@ +package eu.kanade.tachiyomi.ui.watcher + +import com.afollestad.materialdialogs.MaterialDialog +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.WatcherPageSheetBinding +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog + +/** + * Sheet to show when a page is long clicked. + */ +class WatcherPageSheet( + private val activity: WatcherActivity, + private val page: WatcherPage +) : BaseBottomSheetDialog(activity) { + + private val binding = WatcherPageSheetBinding.inflate(activity.layoutInflater, null, false) + + init { + setContentView(binding.root) + + binding.setAsCoverLayout.setOnClickListener { setAsCover() } + binding.shareLayout.setOnClickListener { share() } + binding.saveLayout.setOnClickListener { save() } + } + + /** + * Sets the image of this page as the cover of the anime. + */ + private fun setAsCover() { + if (page.status != Page.READY) return + + MaterialDialog(activity) + .message(R.string.confirm_set_image_as_cover) + .positiveButton(android.R.string.ok) { + activity.setAsCover(page) + dismiss() + } + .negativeButton(android.R.string.cancel) + .show() + } + + /** + * Shares the image of this page with external apps. + */ + private fun share() { + activity.shareImage(page) + dismiss() + } + + /** + * Saves the image of this page on external storage. + */ + private fun save() { + activity.saveImage(page) + dismiss() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderSeekBar.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderSeekBar.kt new file mode 100644 index 000000000..5ddf8e453 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/ReaderSeekBar.kt @@ -0,0 +1,44 @@ +package eu.kanade.tachiyomi.ui.watcher + +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.appcompat.widget.AppCompatSeekBar + +/** + * Seekbar to show current episode progress. + */ +class WatcherSeekBar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppCompatSeekBar(context, attrs) { + + /** + * Whether the seekbar should draw from right to left. + */ + var isRTL = false + + /** + * Draws the seekbar, translating the canvas if using a right to left watcher. + */ + override fun draw(canvas: Canvas) { + if (isRTL) { + val px = width / 2f + val py = height / 2f + + canvas.scale(-1f, 1f, px, py) + } + super.draw(canvas) + } + + /** + * Handles touch events, translating coordinates if using a right to left watcher. + */ + override fun onTouchEvent(event: MotionEvent): Boolean { + if (isRTL) { + event.setLocation(width - event.x, event.y) + } + return super.onTouchEvent(event) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/SaveImageNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/SaveImageNotifier.kt new file mode 100644 index 000000000..3013670ad --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/SaveImageNotifier.kt @@ -0,0 +1,108 @@ +package eu.kanade.tachiyomi.ui.watcher + +import android.content.Context +import android.graphics.Bitmap +import androidx.core.app.NotificationCompat +import com.bumptech.glide.load.engine.DiskCacheStrategy +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.notification.NotificationHandler +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.util.system.notificationBuilder +import eu.kanade.tachiyomi.util.system.notificationManager +import java.io.File + +/** + * Class used to show BigPictureStyle notifications + */ +class SaveImageNotifier(private val context: Context) { + + /** + * Notification builder. + */ + private val notificationBuilder = context.notificationBuilder(Notifications.CHANNEL_COMMON) + + /** + * Id of the notification. + */ + private val notificationId: Int + get() = Notifications.ID_DOWNLOAD_IMAGE + + /** + * Called when image download/copy is complete. This method must be called in a background + * thread. + * + * @param file image file containing downloaded page image. + */ + fun onComplete(file: File) { + val bitmap = GlideApp.with(context) + .asBitmap() + .load(file) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .submit(720, 1280) + .get() + + if (bitmap != null) { + showCompleteNotification(file, bitmap) + } else { + onError(null) + } + } + + private fun showCompleteNotification(file: File, image: Bitmap) { + with(notificationBuilder) { + setContentTitle(context.getString(R.string.picture_saved)) + setSmallIcon(R.drawable.ic_photo_24dp) + setStyle(NotificationCompat.BigPictureStyle().bigPicture(image)) + setLargeIcon(image) + setAutoCancel(true) + + // Clear old actions if they exist + clearActions() + + setContentIntent(NotificationHandler.openImagePendingActivity(context, file)) + // Share action + addAction( + R.drawable.ic_share_24dp, + context.getString(R.string.action_share), + NotificationReceiver.shareImagePendingBroadcast(context, file.absolutePath, notificationId) + ) + // Delete action + addAction( + R.drawable.ic_delete_24dp, + context.getString(R.string.action_delete), + NotificationReceiver.deleteImagePendingBroadcast(context, file.absolutePath, notificationId) + ) + + updateNotification() + } + } + + /** + * Clears the notification message. + */ + fun onClear() { + context.notificationManager.cancel(notificationId) + } + + private fun updateNotification() { + // Displays the progress bar on notification + context.notificationManager.notify(notificationId, notificationBuilder.build()) + } + + /** + * Called on error while downloading image. + * @param error string containing error information. + */ + fun onError(error: String?) { + // Create notification + with(notificationBuilder) { + setContentTitle(context.getString(R.string.download_notifier_title_error)) + setContentText(error ?: context.getString(R.string.unknown_error)) + setSmallIcon(android.R.drawable.ic_menu_report_image) + } + updateNotification() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/WatcherActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/WatcherActivity.kt index f78590624..386bc41e8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/WatcherActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/WatcherActivity.kt @@ -21,15 +21,18 @@ import android.widget.SeekBar import android.widget.Toast import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.setPadding import androidx.lifecycle.lifecycleScope import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.Episode import eu.kanade.tachiyomi.data.database.models.Anime import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.asImmediateFlow +import eu.kanade.tachiyomi.data.preference.toggle import eu.kanade.tachiyomi.databinding.WatcherActivityBinding import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity import eu.kanade.tachiyomi.ui.main.MainActivity @@ -37,12 +40,12 @@ import eu.kanade.tachiyomi.ui.anime.AnimeController import eu.kanade.tachiyomi.ui.watcher.WatcherPresenter.SetAsCoverResult.AddToLibraryFirst import eu.kanade.tachiyomi.ui.watcher.WatcherPresenter.SetAsCoverResult.Error import eu.kanade.tachiyomi.ui.watcher.WatcherPresenter.SetAsCoverResult.Success -import eu.kanade.tachiyomi.ui.watcher.model.WatcherChapter +import eu.kanade.tachiyomi.ui.watcher.model.WatcherEpisode import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage -import eu.kanade.tachiyomi.ui.watcher.model.ViewerChapters +import eu.kanade.tachiyomi.ui.watcher.model.ViewerEpisodes import eu.kanade.tachiyomi.ui.watcher.setting.OrientationType import eu.kanade.tachiyomi.ui.watcher.setting.WatcherSettingsSheet -import eu.kanade.tachiyomi.ui.watcher.setting.WatchingModeType +import eu.kanade.tachiyomi.ui.watcher.setting.ReadingModeType import eu.kanade.tachiyomi.ui.watcher.viewer.BaseViewer import eu.kanade.tachiyomi.ui.watcher.viewer.pager.L2RPagerViewer import eu.kanade.tachiyomi.ui.watcher.viewer.pager.R2LPagerViewer @@ -55,6 +58,7 @@ import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.view.defaultBar import eu.kanade.tachiyomi.util.view.hideBar import eu.kanade.tachiyomi.util.view.isDefaultBar +import eu.kanade.tachiyomi.util.view.setTooltip import eu.kanade.tachiyomi.util.view.showBar import eu.kanade.tachiyomi.widget.SimpleAnimationListener import eu.kanade.tachiyomi.widget.SimpleSeekBarListener @@ -77,7 +81,7 @@ import kotlin.math.abs class WatcherActivity : BaseRxActivity() { companion object { - fun newIntent(context: Context, anime: Anime, episode: Chapter): Intent { + fun newIntent(context: Context, anime: Anime, episode: Episode): Intent { return Intent(context, WatcherActivity::class.java).apply { putExtra("anime", anime.id) putExtra("episode", episode.id) @@ -128,9 +132,9 @@ class WatcherActivity : BaseRxActivity override fun onCreate(savedInstanceState: Bundle?) { setTheme( when (preferences.watcherTheme().get()) { - 0 -> R.style.Theme_Watcher_Light - 2 -> R.style.Theme_Watcher_Dark_Grey - else -> R.style.Theme_Watcher_Dark + 0 -> R.style.Theme_Reader_Light + 2 -> R.style.Theme_Reader_Dark_Grey + else -> R.style.Theme_Reader_Dark } ) super.onCreate(savedInstanceState) @@ -214,9 +218,9 @@ class WatcherActivity : BaseRxActivity override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.watcher, menu) - val isChapterBookmarked = presenter?.getCurrentChapter()?.episode?.bookmark ?: false - menu.findItem(R.id.action_bookmark).isVisible = !isChapterBookmarked - menu.findItem(R.id.action_remove_bookmark).isVisible = isChapterBookmarked + val isEpisodeBookmarked = presenter?.getCurrentEpisode()?.episode?.bookmark ?: false + menu.findItem(R.id.action_bookmark).isVisible = !isEpisodeBookmarked + menu.findItem(R.id.action_remove_bookmark).isVisible = isEpisodeBookmarked return true } @@ -228,11 +232,11 @@ class WatcherActivity : BaseRxActivity override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_bookmark -> { - presenter.bookmarkCurrentChapter(true) + presenter.bookmarkCurrentEpisode(true) invalidateOptionsMenu() } R.id.action_remove_bookmark -> { - presenter.bookmarkCurrentChapter(false) + presenter.bookmarkCurrentEpisode(false) invalidateOptionsMenu() } } @@ -250,10 +254,10 @@ class WatcherActivity : BaseRxActivity override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { if (keyCode == KeyEvent.KEYCODE_N) { - presenter.loadNextChapter() + presenter.loadNextEpisode() return true } else if (keyCode == KeyEvent.KEYCODE_P) { - presenter.loadPreviousChapter() + presenter.loadPreviousEpisode() return true } return super.onKeyUp(keyCode, event) @@ -321,21 +325,21 @@ class WatcherActivity : BaseRxActivity } } ) - binding.leftChapter.setOnClickListener { + binding.leftEpisode.setOnClickListener { if (viewer != null) { if (viewer is R2LPagerViewer) { - loadNextChapter() + loadNextEpisode() } else { - loadPreviousChapter() + loadPreviousEpisode() } } } - binding.rightChapter.setOnClickListener { + binding.rightEpisode.setOnClickListener { if (viewer != null) { if (viewer is R2LPagerViewer) { - loadPreviousChapter() + loadPreviousEpisode() } else { - loadNextChapter() + loadNextEpisode() } } } @@ -347,18 +351,18 @@ class WatcherActivity : BaseRxActivity } private fun initBottomShortcuts() { - // Watching mode - with(binding.actionWatchingMode) { + // Reading mode + with(binding.actionReadingMode) { setTooltip(R.string.viewer) setOnClickListener { - val newWatchingMode = - WatchingModeType.getNextWatchingMode(presenter.getAnimeViewer(resolveDefault = false)) - presenter.setAnimeViewer(newWatchingMode.prefValue) + val newReadingMode = + ReadingModeType.getNextReadingMode(presenter.getAnimeViewer(resolveDefault = false)) + presenter.setAnimeViewer(newReadingMode.prefValue) menuToggleToast?.cancel() - if (!preferences.showWatchingMode()) { - menuToggleToast = toast(newWatchingMode.stringRes) + if (!preferences.showReadingMode()) { + menuToggleToast = toast(newReadingMode.stringRes) } } } @@ -386,7 +390,7 @@ class WatcherActivity : BaseRxActivity setTooltip(R.string.pref_crop_borders) setOnClickListener { - val isPagerType = WatchingModeType.isPagerType(presenter.getAnimeViewer()) + val isPagerType = ReadingModeType.isPagerType(presenter.getAnimeViewer()) if (isPagerType) { preferences.cropBorders().toggle() } else { @@ -423,7 +427,7 @@ class WatcherActivity : BaseRxActivity } private fun updateCropBordersShortcut() { - val isPagerType = WatchingModeType.isPagerType(presenter.getAnimeViewer()) + val isPagerType = ReadingModeType.isPagerType(presenter.getAnimeViewer()) val enabled = if (isPagerType) { preferences.cropBorders().get() } else { @@ -515,14 +519,14 @@ class WatcherActivity : BaseRxActivity fun setAnime(anime: Anime) { val prevViewer = viewer - val viewerMode = WatchingModeType.fromPreference(presenter.getAnimeViewer(resolveDefault = false)) - binding.actionWatchingMode.setImageResource(viewerMode.iconRes) + val viewerMode = ReadingModeType.fromPreference(presenter.getAnimeViewer(resolveDefault = false)) + binding.actionReadingMode.setImageResource(viewerMode.iconRes) val newViewer = when (presenter.getAnimeViewer()) { - WatchingModeType.LEFT_TO_RIGHT.prefValue -> L2RPagerViewer(this) - WatchingModeType.VERTICAL.prefValue -> VerticalPagerViewer(this) - WatchingModeType.WEBTOON.prefValue -> WebtoonViewer(this) - WatchingModeType.CONTINUOUS_VERTICAL.prefValue -> WebtoonViewer(this, isContinuous = false) + ReadingModeType.LEFT_TO_RIGHT.prefValue -> L2RPagerViewer(this) + ReadingModeType.VERTICAL.prefValue -> VerticalPagerViewer(this) + ReadingModeType.WEBTOON.prefValue -> WebtoonViewer(this) + ReadingModeType.CONTINUOUS_VERTICAL.prefValue -> WebtoonViewer(this, isContinuous = false) else -> R2LPagerViewer(this) } @@ -534,26 +538,26 @@ class WatcherActivity : BaseRxActivity viewer = newViewer binding.viewerContainer.addView(newViewer.getView()) - if (preferences.showWatchingMode()) { - showWatchingModeToast(presenter.getAnimeViewer()) + if (preferences.showReadingMode()) { + showReadingModeToast(presenter.getAnimeViewer()) } binding.toolbar.title = anime.title binding.pageSeekbar.isRTL = newViewer is R2LPagerViewer if (newViewer is R2LPagerViewer) { - binding.leftChapter.setTooltip(R.string.action_next_episode) - binding.rightChapter.setTooltip(R.string.action_previous_episode) + binding.leftEpisode.setTooltip(R.string.action_next_episode) + binding.rightEpisode.setTooltip(R.string.action_previous_episode) } else { - binding.leftChapter.setTooltip(R.string.action_previous_episode) - binding.rightChapter.setTooltip(R.string.action_next_episode) + binding.leftEpisode.setTooltip(R.string.action_previous_episode) + binding.rightEpisode.setTooltip(R.string.action_next_episode) } binding.pleaseWait.isVisible = true binding.pleaseWait.startAnimation(AnimationUtils.loadAnimation(this, R.anim.fade_in_long)) } - private fun showWatchingModeToast(mode: Int) { + private fun showReadingModeToast(mode: Int) { try { val strings = resources.getStringArray(R.array.viewers_selector) readingModeToast?.cancel() @@ -564,13 +568,13 @@ class WatcherActivity : BaseRxActivity } /** - * Called from the presenter whenever a new [viewerChapters] have been set. It delegates the + * Called from the presenter whenever a new [viewerEpisodes] have been set. It delegates the * method to the current viewer, but also set the subtitle on the toolbar. */ - fun setChapters(viewerChapters: ViewerChapters) { + fun setEpisodes(viewerEpisodes: ViewerEpisodes) { binding.pleaseWait.isVisible = false - viewer?.setChapters(viewerChapters) - binding.toolbar.subtitle = viewerChapters.currChapter.episode.name + viewer?.setEpisodes(viewerEpisodes) + binding.toolbar.subtitle = viewerEpisodes.currEpisode.episode.name // Invalidate menu to show proper episode bookmark state invalidateOptionsMenu() @@ -580,7 +584,7 @@ class WatcherActivity : BaseRxActivity * Called from the presenter if the initial load couldn't load the pages of the episode. In * this case the activity is closed and a toast is shown to the user. */ - fun setInitialChapterError(error: Throwable) { + fun setInitialEpisodeError(error: Throwable) { Timber.e(error) finish() toast(error.message) @@ -608,8 +612,8 @@ class WatcherActivity : BaseRxActivity */ fun moveToPageIndex(index: Int) { val viewer = viewer ?: return - val currentChapter = presenter.getCurrentChapter() ?: return - val page = currentChapter.pages?.getOrNull(index) ?: return + val currentEpisode = presenter.getCurrentEpisode() ?: return + val page = currentEpisode.pages?.getOrNull(index) ?: return viewer.moveToPage(page) } @@ -617,16 +621,16 @@ class WatcherActivity : BaseRxActivity * Tells the presenter to load the next episode and mark it as active. The progress dialog * should be automatically shown. */ - private fun loadNextChapter() { - presenter.loadNextChapter() + private fun loadNextEpisode() { + presenter.loadNextEpisode() } /** * Tells the presenter to load the previous episode and mark it as active. The progress dialog * should be automatically shown. */ - private fun loadPreviousChapter() { - presenter.loadPreviousChapter() + private fun loadPreviousEpisode() { + presenter.loadPreviousEpisode() } /** @@ -667,8 +671,8 @@ class WatcherActivity : BaseRxActivity * Called from the viewer when the given [episode] should be preloaded. It should be called when * the viewer is reaching the beginning or end of a episode or the transition page is active. */ - fun requestPreloadChapter(episode: WatcherChapter) { - presenter.preloadChapter(episode) + fun requestPreloadEpisode(episode: WatcherEpisode) { + presenter.preloadEpisode(episode) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/WatcherPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/WatcherPresenter.kt new file mode 100644 index 000000000..02ca288ac --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/WatcherPresenter.kt @@ -0,0 +1,717 @@ +package eu.kanade.tachiyomi.ui.watcher + +import android.app.Application +import android.os.Bundle +import android.os.Environment +import com.jakewharton.rxrelay.BehaviorRelay +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.History +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.ui.watcher.loader.EpisodeLoader +import eu.kanade.tachiyomi.ui.watcher.model.WatcherEpisode +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import eu.kanade.tachiyomi.ui.watcher.model.ViewerEpisodes +import eu.kanade.tachiyomi.util.isLocal +import eu.kanade.tachiyomi.util.lang.byteSize +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.lang.takeBytes +import eu.kanade.tachiyomi.util.storage.DiskUtil +import eu.kanade.tachiyomi.util.system.ImageUtil +import eu.kanade.tachiyomi.util.updateCoverLastModified +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import rx.Observable +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.File +import java.util.Date +import java.util.concurrent.TimeUnit + +/** + * Presenter used by the activity to perform background operations. + */ +class WatcherPresenter( + private val db: DatabaseHelper = Injekt.get(), + private val sourceManager: SourceManager = Injekt.get(), + private val downloadManager: DownloadManager = Injekt.get(), + private val coverCache: CoverCache = Injekt.get(), + private val preferences: PreferencesHelper = Injekt.get() +) : BasePresenter() { + + /** + * The anime loaded in the watcher. It can be null when instantiated for a short time. + */ + var anime: Anime? = null + private set + + /** + * The episode id of the currently loaded episode. Used to restore from process kill. + */ + private var episodeId = -1L + + /** + * The episode loader for the loaded anime. It'll be null until [anime] is set. + */ + private var loader: EpisodeLoader? = null + + /** + * Subscription to prevent setting episodes as active from multiple threads. + */ + private var activeEpisodeSubscription: Subscription? = null + + /** + * Relay for currently active viewer episodes. + */ + private val viewerEpisodesRelay = BehaviorRelay.create() + + /** + * Relay used when loading prev/next episode needed to lock the UI (with a dialog). + */ + private val isLoadingAdjacentEpisodeRelay = BehaviorRelay.create() + + /** + * Episode list for the active anime. It's retrieved lazily and should be accessed for the first + * time in a background thread to avoid blocking the UI. + */ + private val episodeList by lazy { + val anime = anime!! + val dbEpisodes = db.getEpisodes(anime).executeAsBlocking() + + val selectedEpisode = dbEpisodes.find { it.id == episodeId } + ?: error("Requested episode of id $episodeId not found in episode list") + + val episodesForWatcher = + if (preferences.skipRead() || preferences.skipFiltered()) { + val list = dbEpisodes + .filter { + if (preferences.skipRead() && it.read) { + return@filter false + } else if (preferences.skipFiltered()) { + if ( + (anime.readFilter == Anime.SHOW_READ && !it.read) || + (anime.readFilter == Anime.SHOW_UNREAD && it.read) || + ( + anime.downloadedFilter == Anime.SHOW_DOWNLOADED && + !downloadManager.isEpisodeDownloaded(it, anime) + ) || + (anime.bookmarkedFilter == Anime.SHOW_BOOKMARKED && !it.bookmark) + ) { + return@filter false + } + } + + true + } + .toMutableList() + + val find = list.find { it.id == episodeId } + if (find == null) { + list.add(selectedEpisode) + } + list + } else { + dbEpisodes + } + + when (anime.sorting) { + Anime.SORTING_SOURCE -> EpisodeLoadBySource().get(episodesForWatcher) + Anime.SORTING_NUMBER -> EpisodeLoadByNumber().get(episodesForWatcher, selectedEpisode) + Anime.SORTING_UPLOAD_DATE -> EpisodeLoadByUploadDate().get(episodesForWatcher) + else -> error("Unknown sorting method") + }.map(::WatcherEpisode) + } + + private var hasTrackers: Boolean = false + private val checkTrackers: (Anime) -> Unit = { anime -> + val tracks = db.getTracks(anime).executeAsBlocking() + + hasTrackers = tracks.size > 0 + } + + /** + * Called when the presenter is created. It retrieves the saved active episode if the process + * was restored. + */ + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + if (savedState != null) { + episodeId = savedState.getLong(::episodeId.name, -1) + } + } + + /** + * Called when the presenter is destroyed. It saves the current progress and cleans up + * references on the currently active episodes. + */ + override fun onDestroy() { + super.onDestroy() + val currentEpisodes = viewerEpisodesRelay.value + if (currentEpisodes != null) { + currentEpisodes.unref() + saveEpisodeProgress(currentEpisodes.currEpisode) + saveEpisodeHistory(currentEpisodes.currEpisode) + } + } + + /** + * Called when the presenter instance is being saved. It saves the currently active episode + * id and the last page read. + */ + override fun onSave(state: Bundle) { + super.onSave(state) + val currentEpisode = getCurrentEpisode() + if (currentEpisode != null) { + currentEpisode.requestedPage = currentEpisode.episode.last_page_read + state.putLong(::episodeId.name, currentEpisode.episode.id!!) + } + } + + /** + * Called when the user pressed the back button and is going to leave the watcher. Used to + * trigger deletion of the downloaded episodes. + */ + fun onBackPressed() { + deletePendingEpisodes() + } + + /** + * Called when the activity is saved and not changing configurations. It updates the database + * to persist the current progress of the active episode. + */ + fun onSaveInstanceStateNonConfigurationChange() { + val currentEpisode = getCurrentEpisode() ?: return + saveEpisodeProgress(currentEpisode) + } + + /** + * Whether this presenter is initialized yet. + */ + fun needsInit(): Boolean { + return anime == null + } + + /** + * Initializes this presenter with the given [animeId] and [initialEpisodeId]. This method will + * fetch the anime from the database and initialize the initial episode. + */ + fun init(animeId: Long, initialEpisodeId: Long) { + if (!needsInit()) return + + db.getAnime(animeId).asRxObservable() + .first() + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { init(it, initialEpisodeId) } + .subscribeFirst( + { _, _ -> + // Ignore onNext event + }, + WatcherActivity::setInitialEpisodeError + ) + } + + /** + * Initializes this presenter with the given [anime] and [initialEpisodeId]. This method will + * set the episode loader, view subscriptions and trigger an initial load. + */ + private fun init(anime: Anime, initialEpisodeId: Long) { + if (!needsInit()) return + + this.anime = anime + if (episodeId == -1L) episodeId = initialEpisodeId + + checkTrackers(anime) + + val context = Injekt.get() + val source = sourceManager.getOrStub(anime.source) + loader = EpisodeLoader(context, downloadManager, anime, source) + + Observable.just(anime).subscribeLatestCache(WatcherActivity::setAnime) + viewerEpisodesRelay.subscribeLatestCache(WatcherActivity::setEpisodes) + isLoadingAdjacentEpisodeRelay.subscribeLatestCache(WatcherActivity::setProgressDialog) + + // Read episodeList from an io thread because it's retrieved lazily and would block main. + activeEpisodeSubscription?.unsubscribe() + activeEpisodeSubscription = Observable + .fromCallable { episodeList.first { episodeId == it.episode.id } } + .flatMap { getLoadObservable(loader!!, it) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { _, _ -> + // Ignore onNext event + }, + WatcherActivity::setInitialEpisodeError + ) + } + + /** + * Returns an observable that loads the given [episode] with this [loader]. This observable + * handles main thread synchronization and updating the currently active episodes on + * [viewerEpisodesRelay], however callers must ensure there won't be more than one + * subscription active by unsubscribing any existing [activeEpisodeSubscription] before. + * Callers must also handle the onError event. + */ + private fun getLoadObservable( + loader: EpisodeLoader, + episode: WatcherEpisode + ): Observable { + return loader.loadEpisode(episode) + .andThen( + Observable.fromCallable { + val episodePos = episodeList.indexOf(episode) + + ViewerEpisodes( + episode, + episodeList.getOrNull(episodePos - 1), + episodeList.getOrNull(episodePos + 1) + ) + } + ) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { newEpisodes -> + val oldEpisodes = viewerEpisodesRelay.value + + // Add new references first to avoid unnecessary recycling + newEpisodes.ref() + oldEpisodes?.unref() + + viewerEpisodesRelay.call(newEpisodes) + } + } + + /** + * Called when the user changed to the given [episode] when changing pages from the viewer. + * It's used only to set this episode as active. + */ + private fun loadNewEpisode(episode: WatcherEpisode) { + val loader = loader ?: return + + Timber.d("Loading ${episode.episode.url}") + + activeEpisodeSubscription?.unsubscribe() + activeEpisodeSubscription = getLoadObservable(loader, episode) + .toCompletable() + .onErrorComplete() + .subscribe() + .also(::add) + } + + /** + * Called when the user is going to load the prev/next episode through the menu button. It + * sets the [isLoadingAdjacentEpisodeRelay] that the view uses to prevent any further + * interaction until the episode is loaded. + */ + private fun loadAdjacent(episode: WatcherEpisode) { + val loader = loader ?: return + + Timber.d("Loading adjacent ${episode.episode.url}") + + activeEpisodeSubscription?.unsubscribe() + activeEpisodeSubscription = getLoadObservable(loader, episode) + .doOnSubscribe { isLoadingAdjacentEpisodeRelay.call(true) } + .doOnUnsubscribe { isLoadingAdjacentEpisodeRelay.call(false) } + .subscribeFirst( + { view, _ -> + view.moveToPageIndex(0) + }, + { _, _ -> + // Ignore onError event, viewers handle that state + } + ) + } + + /** + * Called when the viewers decide it's a good time to preload a [episode] and improve the UX so + * that the user doesn't have to wait too long to continue reading. + */ + private fun preload(episode: WatcherEpisode) { + if (episode.state != WatcherEpisode.State.Wait && episode.state !is WatcherEpisode.State.Error) { + return + } + + Timber.d("Preloading ${episode.episode.url}") + + val loader = loader ?: return + + loader.loadEpisode(episode) + .observeOn(AndroidSchedulers.mainThread()) + // Update current episodes whenever a episode is preloaded + .doOnCompleted { viewerEpisodesRelay.value?.let(viewerEpisodesRelay::call) } + .onErrorComplete() + .subscribe() + .also(::add) + } + + /** + * Called every time a page changes on the watcher. Used to mark the flag of episodes being + * read, update tracking services, enqueue downloaded episode deletion, and updating the active episode if this + * [page]'s episode is different from the currently active. + */ + fun onPageSelected(page: WatcherPage) { + val currentEpisodes = viewerEpisodesRelay.value ?: return + + val selectedEpisode = page.episode + + // Save last page read and mark as read if needed + selectedEpisode.episode.last_page_read = page.index + val shouldTrack = !preferences.incognitoMode().get() || hasTrackers + if (selectedEpisode.pages?.lastIndex == page.index && shouldTrack) { + selectedEpisode.episode.read = true + updateTrackEpisodeRead(selectedEpisode) + deleteEpisodeIfNeeded(selectedEpisode) + deleteEpisodeFromDownloadQueue(currentEpisodes.currEpisode) + } + + if (selectedEpisode != currentEpisodes.currEpisode) { + Timber.d("Setting ${selectedEpisode.episode.url} as active") + onEpisodeChanged(currentEpisodes.currEpisode) + loadNewEpisode(selectedEpisode) + } + } + + /** + * Removes [currentEpisode] from download queue + * if setting is enabled and [currentEpisode] is queued for download + */ + private fun deleteEpisodeFromDownloadQueue(currentEpisode: WatcherEpisode) { + downloadManager.getEpisodeDownloadOrNull(currentEpisode.episode)?.let { download -> + downloadManager.deletePendingDownload(download) + } + } + + /** + * Determines if deleting option is enabled and nth to last episode actually exists. + * If both conditions are satisfied enqueues episode for delete + * @param currentEpisode current episode, which is going to be marked as read. + */ + private fun deleteEpisodeIfNeeded(currentEpisode: WatcherEpisode) { + // Determine which episode should be deleted and enqueue + val currentEpisodePosition = episodeList.indexOf(currentEpisode) + val removeAfterReadSlots = preferences.removeAfterReadSlots() + val episodeToDelete = episodeList.getOrNull(currentEpisodePosition - removeAfterReadSlots) + // Check if deleting option is enabled and episode exists + if (removeAfterReadSlots != -1 && episodeToDelete != null) { + enqueueDeleteReadEpisodes(episodeToDelete) + } + } + + /** + * Called when a episode changed from [fromEpisode] to [toEpisode]. It updates [fromEpisode] + * on the database. + */ + private fun onEpisodeChanged(fromEpisode: WatcherEpisode) { + saveEpisodeProgress(fromEpisode) + saveEpisodeHistory(fromEpisode) + } + + /** + * Saves this [episode] progress (last read page and whether it's read). + * If incognito mode isn't on or has at least 1 tracker + */ + private fun saveEpisodeProgress(episode: WatcherEpisode) { + if (!preferences.incognitoMode().get() || hasTrackers) { + db.updateEpisodeProgress(episode.episode).asRxCompletable() + .onErrorComplete() + .subscribeOn(Schedulers.io()) + .subscribe() + } + } + + /** + * Saves this [episode] last read history if incognito mode isn't on. + */ + private fun saveEpisodeHistory(episode: WatcherEpisode) { + if (!preferences.incognitoMode().get()) { + val history = History.create(episode.episode).apply { last_read = Date().time } + db.updateHistoryLastRead(history).asRxCompletable() + .onErrorComplete() + .subscribeOn(Schedulers.io()) + .subscribe() + } + } + + /** + * Called from the activity to preload the given [episode]. + */ + fun preloadEpisode(episode: WatcherEpisode) { + preload(episode) + } + + /** + * Called from the activity to load and set the next episode as active. + */ + fun loadNextEpisode() { + val nextEpisode = viewerEpisodesRelay.value?.nextEpisode ?: return + loadAdjacent(nextEpisode) + } + + /** + * Called from the activity to load and set the previous episode as active. + */ + fun loadPreviousEpisode() { + val prevEpisode = viewerEpisodesRelay.value?.prevEpisode ?: return + loadAdjacent(prevEpisode) + } + + /** + * Returns the currently active episode. + */ + fun getCurrentEpisode(): WatcherEpisode? { + return viewerEpisodesRelay.value?.currEpisode + } + + /** + * Bookmarks the currently active episode. + */ + fun bookmarkCurrentEpisode(bookmarked: Boolean) { + if (getCurrentEpisode()?.episode == null) { + return + } + + val episode = getCurrentEpisode()?.episode!! + episode.bookmark = bookmarked + db.updateEpisodeProgress(episode).executeAsBlocking() + } + + /** + * Returns the viewer position used by this anime or the default one. + */ + fun getAnimeViewer(resolveDefault: Boolean = true): Int { + val anime = anime ?: return preferences.defaultViewer() + return if (resolveDefault && anime.viewer == 0) preferences.defaultViewer() else anime.viewer + } + + /** + * Updates the viewer position for the open anime. + */ + fun setAnimeViewer(viewer: Int) { + val anime = anime ?: return + anime.viewer = viewer + db.updateAnimeViewer(anime).executeAsBlocking() + + Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) + .subscribeFirst({ view, _ -> + val currEpisodes = viewerEpisodesRelay.value + if (currEpisodes != null) { + // Save current page + val currEpisode = currEpisodes.currEpisode + currEpisode.requestedPage = currEpisode.episode.last_page_read + + // Emit anime and episodes to the new viewer + view.setAnime(anime) + view.setEpisodes(currEpisodes) + } + }) + } + + /** + * Saves the image of this [page] in the given [directory] and returns the file location. + */ + private fun saveImage(page: WatcherPage, directory: File, anime: Anime): File { + val stream = page.stream!! + val type = ImageUtil.findImageType(stream) ?: throw Exception("Not an image") + + directory.mkdirs() + + val episode = page.episode.episode + + // Build destination file. + val filenameSuffix = " - ${page.number}.${type.extension}" + val filename = DiskUtil.buildValidFilename( + "${anime.title} - ${episode.name}".takeBytes(MAX_FILE_NAME_BYTES - filenameSuffix.byteSize()) + ) + filenameSuffix + + val destFile = File(directory, filename) + stream().use { input -> + destFile.outputStream().use { output -> + input.copyTo(output) + } + } + return destFile + } + + /** + * Saves the image of this [page] on the pictures directory and notifies the UI of the result. + * There's also a notification to allow sharing the image somewhere else or deleting it. + */ + fun saveImage(page: WatcherPage) { + if (page.status != Page.READY) return + val anime = anime ?: return + val context = Injekt.get() + + val notifier = SaveImageNotifier(context) + notifier.onClear() + + // Pictures directory. + val destDir = File( + Environment.getExternalStorageDirectory().absolutePath + + File.separator + Environment.DIRECTORY_PICTURES + + File.separator + context.getString(R.string.app_name) + ) + + // Copy file in background. + Observable.fromCallable { saveImage(page, destDir, anime) } + .doOnNext { file -> + DiskUtil.scanMedia(context, file) + notifier.onComplete(file) + } + .doOnError { notifier.onError(it.message) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) }, + { view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) } + ) + } + + /** + * Shares the image of this [page] and notifies the UI with the path of the file to share. + * The image must be first copied to the internal partition because there are many possible + * formats it can come from, like a zipped episode, in which case it's not possible to directly + * get a path to the file and it has to be decompresssed somewhere first. Only the last shared + * image will be kept so it won't be taking lots of internal disk space. + */ + fun shareImage(page: WatcherPage) { + if (page.status != Page.READY) return + val anime = anime ?: return + val context = Injekt.get() + + val destDir = File(context.cacheDir, "shared_image") + + Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file + .map { saveImage(page, destDir, anime) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, file -> view.onShareImageResult(file, page) }, + { _, _ -> /* Empty */ } + ) + } + + /** + * Sets the image of this [page] as cover and notifies the UI of the result. + */ + fun setAsCover(page: WatcherPage) { + if (page.status != Page.READY) return + val anime = anime ?: return + val stream = page.stream ?: return + + Observable + .fromCallable { + if (anime.isLocal()) { + val context = Injekt.get() + LocalSource.updateCover(context, anime, stream()) + anime.updateCoverLastModified(db) + R.string.cover_updated + SetAsCoverResult.Success + } else { + if (anime.favorite) { + coverCache.setCustomCoverToCache(anime, stream()) + anime.updateCoverLastModified(db) + SetAsCoverResult.Success + } else { + SetAsCoverResult.AddToLibraryFirst + } + } + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, result -> view.onSetAsCoverResult(result) }, + { view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) } + ) + } + + /** + * Results of the set as cover feature. + */ + enum class SetAsCoverResult { + Success, AddToLibraryFirst, Error + } + + /** + * Results of the save image feature. + */ + sealed class SaveImageResult { + class Success(val file: File) : SaveImageResult() + class Error(val error: Throwable) : SaveImageResult() + } + + /** + * Starts the service that updates the last episode read in sync services. This operation + * will run in a background thread and errors are ignored. + */ + private fun updateTrackEpisodeRead(watcherEpisode: WatcherEpisode) { + if (!preferences.autoUpdateTrack()) return + val anime = anime ?: return + + val episodeRead = watcherEpisode.episode.episode_number.toInt() + + val trackManager = Injekt.get() + + launchIO { + db.getTracks(anime).executeAsBlocking() + .mapNotNull { track -> + val service = trackManager.getService(track.sync_id) + if (service != null && service.isLogged && episodeRead > track.last_episode_read) { + track.last_episode_read = episodeRead + + // We want these to execute even if the presenter is destroyed and leaks + // for a while. The view can still be garbage collected. + async { + runCatching { + service.update(track) + db.insertTrack(track).executeAsBlocking() + } + } + } else { + null + } + } + .awaitAll() + .mapNotNull { it.exceptionOrNull() } + .forEach { Timber.w(it) } + } + } + + /** + * Enqueues this [episode] to be deleted when [deletePendingEpisodes] is called. The download + * manager handles persisting it across process deaths. + */ + private fun enqueueDeleteReadEpisodes(episode: WatcherEpisode) { + if (!episode.episode.read) return + val anime = anime ?: return + + launchIO { + downloadManager.enqueueDeleteEpisodes(listOf(episode.episode), anime) + } + } + + /** + * Deletes all the pending episodes. This operation will run in a background thread and errors + * are ignored. + */ + private fun deletePendingEpisodes() { + launchIO { + downloadManager.deletePendingEpisodes() + } + } + + companion object { + // Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8) + private const val MAX_FILE_NAME_BYTES = 250 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/DirectoryPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/DirectoryPageLoader.kt new file mode 100644 index 000000000..7da4a8084 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/DirectoryPageLoader.kt @@ -0,0 +1,40 @@ +package eu.kanade.tachiyomi.ui.watcher.loader + +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder +import eu.kanade.tachiyomi.util.system.ImageUtil +import rx.Observable +import java.io.File +import java.io.FileInputStream + +/** + * Loader used to load a episode from a directory given on [file]. + */ +class DirectoryPageLoader(val file: File) : PageLoader() { + + /** + * Returns an observable containing the pages found on this directory ordered with a natural + * comparator. + */ + override fun getPages(): Observable> { + return file.listFiles() + .filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } + .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } + .mapIndexed { i, file -> + val streamFn = { FileInputStream(file) } + WatcherPage(i).apply { + stream = streamFn + status = Page.READY + } + } + .let { Observable.just(it) } + } + + /** + * Returns an observable that emits a ready state. + */ + override fun getPage(page: WatcherPage): Observable { + return Observable.just(Page.READY) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/DownloadPageLoader.kt new file mode 100644 index 000000000..fec2aa52e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/DownloadPageLoader.kt @@ -0,0 +1,46 @@ +package eu.kanade.tachiyomi.ui.watcher.loader + +import android.app.Application +import android.net.Uri +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.data.download.AnimeDownloadManager +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.ui.watcher.model.WatcherEpisode +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import rx.Observable +import uy.kohesive.injekt.injectLazy + +/** + * Loader used to load a episode from the downloaded episodes. + */ +class DownloadPageLoader( + private val episode: WatcherEpisode, + private val anime: Anime, + private val source: Source, + private val downloadManager: AnimeDownloadManager +) : PageLoader() { + + // Needed to open input streams + private val context: Application by injectLazy() + + /** + * Returns an observable containing the pages found on this downloaded episode. + */ + override fun getPages(): Observable> { + return downloadManager.buildPageList(source, anime, episode.episode) + .map { pages -> + pages.map { page -> + WatcherPage(page.index, page.url, page.imageUrl) { + context.contentResolver.openInputStream(page.uri ?: Uri.EMPTY)!! + }.apply { + status = Page.READY + } + } + } + } + + override fun getPage(page: WatcherPage): Observable { + return Observable.just(Page.READY) // TODO maybe check if file still exists? + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/EpisodeLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/EpisodeLoader.kt new file mode 100644 index 000000000..32df6d52d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/EpisodeLoader.kt @@ -0,0 +1,93 @@ +package eu.kanade.tachiyomi.ui.watcher.loader + +import android.content.Context +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Anime +import eu.kanade.tachiyomi.data.download.AnimeDownloadManager +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.online.AnimeHttpSource +import eu.kanade.tachiyomi.ui.watcher.model.WatcherEpisode +import rx.Completable +import rx.Observable +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import timber.log.Timber + +/** + * Loader used to retrieve the [PageLoader] for a given episode. + */ +class EpisodeLoader( + private val context: Context, + private val downloadManager: AnimeDownloadManager, + private val anime: Anime, + private val source: Source +) { + + /** + * Returns a completable that assigns the page loader and loads the its pages. It just + * completes if the episode is already loaded. + */ + fun loadEpisode(episode: WatcherEpisode): Completable { + if (episodeIsReady(episode)) { + return Completable.complete() + } + + return Observable.just(episode) + .doOnNext { episode.state = WatcherEpisode.State.Loading } + .observeOn(Schedulers.io()) + .flatMap { watcherEpisode -> + Timber.d("Loading pages for ${episode.episode.name}") + + val loader = getPageLoader(watcherEpisode) + episode.pageLoader = loader + + loader.getPages().take(1).doOnNext { pages -> + pages.forEach { it.episode = episode } + } + } + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { pages -> + if (pages.isEmpty()) { + throw Exception(context.getString(R.string.page_list_empty_error)) + } + + episode.state = WatcherEpisode.State.Loaded(pages) + + // If the episode is partially read, set the starting page to the last the user read + // otherwise use the requested page. + if (!episode.episode.read) { + episode.requestedPage = episode.episode.last_page_read + } + } + .toCompletable() + .doOnError { episode.state = WatcherEpisode.State.Error(it) } + } + + /** + * Checks [episode] to be loaded based on present pages and loader in addition to state. + */ + private fun episodeIsReady(episode: WatcherEpisode): Boolean { + return episode.state is WatcherEpisode.State.Loaded && episode.pageLoader != null + } + + /** + * Returns the page loader to use for this [episode]. + */ + private fun getPageLoader(episode: WatcherEpisode): PageLoader { + val isDownloaded = downloadManager.isEpisodeDownloaded(episode.episode, anime, true) + return when { + isDownloaded -> DownloadPageLoader(episode, anime, source, downloadManager) + source is AnimeHttpSource -> HttpPageLoader(episode, source) + source is LocalSource -> source.getFormat(episode.episode).let { format -> + when (format) { + is LocalSource.Format.Directory -> DirectoryPageLoader(format.file) + is LocalSource.Format.Zip -> ZipPageLoader(format.file) + is LocalSource.Format.Rar -> RarPageLoader(format.file) + is LocalSource.Format.Epub -> EpubPageLoader(format.file) + } + } + else -> error(context.getString(R.string.loader_not_implemented_error)) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/EpubPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/EpubPageLoader.kt new file mode 100644 index 000000000..c2ec4a5b9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/EpubPageLoader.kt @@ -0,0 +1,55 @@ +package eu.kanade.tachiyomi.ui.watcher.loader + +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import eu.kanade.tachiyomi.util.storage.EpubFile +import rx.Observable +import java.io.File + +/** + * Loader used to load a episode from a .epub file. + */ +class EpubPageLoader(file: File) : PageLoader() { + + /** + * The epub file. + */ + private val epub = EpubFile(file) + + /** + * Recycles this loader and the open zip. + */ + override fun recycle() { + super.recycle() + epub.close() + } + + /** + * Returns an observable containing the pages found on this zip archive ordered with a natural + * comparator. + */ + override fun getPages(): Observable> { + return epub.getImagesFromPages() + .mapIndexed { i, path -> + val streamFn = { epub.getInputStream(epub.getEntry(path)!!) } + WatcherPage(i).apply { + stream = streamFn + status = Page.READY + } + } + .let { Observable.just(it) } + } + + /** + * Returns an observable that emits a ready state unless the loader was recycled. + */ + override fun getPage(page: WatcherPage): Observable { + return Observable.just( + if (isRecycled) { + Page.ERROR + } else { + Page.READY + } + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/HttpPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/HttpPageLoader.kt new file mode 100644 index 000000000..de7f58dd1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/HttpPageLoader.kt @@ -0,0 +1,244 @@ +package eu.kanade.tachiyomi.ui.watcher.loader + +import eu.kanade.tachiyomi.data.cache.EpisodeCache +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.online.AnimeHttpSource +import eu.kanade.tachiyomi.ui.watcher.model.WatcherEpisode +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import eu.kanade.tachiyomi.util.lang.plusAssign +import rx.Completable +import rx.Observable +import rx.schedulers.Schedulers +import rx.subjects.PublishSubject +import rx.subjects.SerializedSubject +import rx.subscriptions.CompositeSubscription +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.concurrent.PriorityBlockingQueue +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.min + +/** + * Loader used to load episodes from an online source. + */ +class HttpPageLoader( + private val episode: WatcherEpisode, + private val source: AnimeHttpSource, + private val episodeCache: EpisodeCache = Injekt.get() +) : PageLoader() { + + /** + * A queue used to manage requests one by one while allowing priorities. + */ + private val queue = PriorityBlockingQueue() + + /** + * Current active subscriptions. + */ + private val subscriptions = CompositeSubscription() + + private val preloadSize = 4 + + init { + subscriptions += Observable.defer { Observable.just(queue.take().page) } + .filter { it.status == Page.QUEUE } + .concatMap { source.fetchImageFromCacheThenNet(it) } + .repeat() + .subscribeOn(Schedulers.io()) + .subscribe( + { + }, + { error -> + if (error !is InterruptedException) { + Timber.e(error) + } + } + ) + } + + /** + * Recycles this loader and the active subscriptions and queue. + */ + override fun recycle() { + super.recycle() + subscriptions.unsubscribe() + queue.clear() + + // Cache current page list progress for online episodes to allow a faster reopen + val pages = episode.pages + if (pages != null) { + Completable + .fromAction { + // Convert to pages without watcher information + val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) } + episodeCache.putPageListToCache(episode.episode, pagesToSave) + } + .onErrorComplete() + .subscribeOn(Schedulers.io()) + .subscribe() + } + } + + /** + * Returns an observable with the page list for a episode. It tries to return the page list from + * the local cache, otherwise fallbacks to network. + */ + override fun getPages(): Observable> { + return episodeCache + .getPageListFromCache(episode.episode) + .onErrorResumeNext { source.fetchPageListAnime(episode.episode) } + .map { pages -> + pages.mapIndexed { index, page -> + // Don't trust sources and use our own indexing + WatcherPage(index, page.url, page.imageUrl) + } + } + } + + /** + * Returns an observable that loads a page through the queue and listens to its result to + * emit new states. It handles re-enqueueing pages if they were evicted from the cache. + */ + override fun getPage(page: WatcherPage): Observable { + return Observable.defer { + val imageUrl = page.imageUrl + + // Check if the image has been deleted + if (page.status == Page.READY && imageUrl != null && !episodeCache.isImageInCache(imageUrl)) { + page.status = Page.QUEUE + } + + // Automatically retry failed pages when subscribed to this page + if (page.status == Page.ERROR) { + page.status = Page.QUEUE + } + + val statusSubject = SerializedSubject(PublishSubject.create()) + page.setStatusSubject(statusSubject) + + val queuedPages = mutableListOf() + if (page.status == Page.QUEUE) { + queuedPages += PriorityPage(page, 1).also { queue.offer(it) } + } + queuedPages += preloadNextPages(page, preloadSize) + + statusSubject.startWith(page.status) + .doOnUnsubscribe { + queuedPages.forEach { + if (it.page.status == Page.QUEUE) { + queue.remove(it) + } + } + } + } + .subscribeOn(Schedulers.io()) + .unsubscribeOn(Schedulers.io()) + } + + /** + * Preloads the given [amount] of pages after the [currentPage] with a lower priority. + * @return a list of [PriorityPage] that were added to the [queue] + */ + private fun preloadNextPages(currentPage: WatcherPage, amount: Int): List { + val pageIndex = currentPage.index + val pages = currentPage.episode.pages ?: return emptyList() + if (pageIndex == pages.lastIndex) return emptyList() + + return pages + .subList(pageIndex + 1, min(pageIndex + 1 + amount, pages.size)) + .mapNotNull { + if (it.status == Page.QUEUE) { + PriorityPage(it, 0).apply { queue.offer(this) } + } else null + } + } + + /** + * Retries a page. This method is only called from user interaction on the viewer. + */ + override fun retryPage(page: WatcherPage) { + if (page.status == Page.ERROR) { + page.status = Page.QUEUE + } + queue.offer(PriorityPage(page, 2)) + } + + /** + * Data class used to keep ordering of pages in order to maintain priority. + */ + private class PriorityPage( + val page: WatcherPage, + val priority: Int + ) : Comparable { + companion object { + private val idGenerator = AtomicInteger() + } + + private val identifier = idGenerator.incrementAndGet() + + override fun compareTo(other: PriorityPage): Int { + val p = other.priority.compareTo(priority) + return if (p != 0) p else identifier.compareTo(other.identifier) + } + } + + /** + * Returns an observable of the page with the downloaded image. + * + * @param page the page whose source image has to be downloaded. + */ + private fun AnimeHttpSource.fetchImageFromCacheThenNet(page: WatcherPage): Observable { + return if (page.imageUrl.isNullOrEmpty()) { + getImageUrl(page).flatMap { getCachedImage(it) } + } else { + getCachedImage(page) + } + } + + private fun AnimeHttpSource.getImageUrl(page: WatcherPage): Observable { + page.status = Page.LOAD_PAGE + return fetchImageUrl(page) + .doOnError { page.status = Page.ERROR } + .onErrorReturn { null } + .doOnNext { page.imageUrl = it } + .map { page } + } + + /** + * Returns an observable of the page that gets the image from the episode or fallbacks to + * network and copies it to the cache calling [cacheImage]. + * + * @param page the page. + */ + private fun AnimeHttpSource.getCachedImage(page: WatcherPage): Observable { + val imageUrl = page.imageUrl ?: return Observable.just(page) + + return Observable.just(page) + .flatMap { + if (!episodeCache.isImageInCache(imageUrl)) { + cacheImage(page) + } else { + Observable.just(page) + } + } + .doOnNext { + page.stream = { episodeCache.getImageFile(imageUrl).inputStream() } + page.status = Page.READY + } + .doOnError { page.status = Page.ERROR } + .onErrorReturn { page } + } + + /** + * Returns an observable of the page that downloads the image to [EpisodeCache]. + * + * @param page the page. + */ + private fun AnimeHttpSource.cacheImage(page: WatcherPage): Observable { + page.status = Page.DOWNLOAD_IMAGE + return fetchImage(page) + .doOnNext { episodeCache.putImageToCache(page.imageUrl!!, it) } + .map { page } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/PageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/PageLoader.kt new file mode 100644 index 000000000..3642a1e8c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/PageLoader.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.ui.watcher.loader + +import androidx.annotation.CallSuper +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import rx.Observable + +/** + * A loader used to load pages into the watcher. Any open resources must be cleaned up when the + * method [recycle] is called. + */ +abstract class PageLoader { + + /** + * Whether this loader has been already recycled. + */ + var isRecycled = false + private set + + /** + * Recycles this loader. Implementations must override this method to clean up any active + * resources. + */ + @CallSuper + open fun recycle() { + isRecycled = true + } + + /** + * Returns an observable containing the list of pages of a episode. Only the first emission + * will be used. + */ + abstract fun getPages(): Observable> + + /** + * Returns an observable that should inform of the progress of the page (see the Page class + * for the available states) + */ + abstract fun getPage(page: WatcherPage): Observable + + /** + * Retries the given [page] in case it failed to load. This method only makes sense when an + * online source is used. + */ + open fun retryPage(page: WatcherPage) {} +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/RarPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/RarPageLoader.kt new file mode 100644 index 000000000..056624410 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/RarPageLoader.kt @@ -0,0 +1,88 @@ +package eu.kanade.tachiyomi.ui.watcher.loader + +import com.github.junrar.Archive +import com.github.junrar.rarfile.FileHeader +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder +import eu.kanade.tachiyomi.util.system.ImageUtil +import rx.Observable +import java.io.File +import java.io.InputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.util.concurrent.Executors + +/** + * Loader used to load a episode from a .rar or .cbr file. + */ +class RarPageLoader(file: File) : PageLoader() { + + /** + * The rar archive to load pages from. + */ + private val archive = Archive(file) + + /** + * Pool for copying compressed files to an input stream. + */ + private val pool = Executors.newFixedThreadPool(1) + + /** + * Recycles this loader and the open archive. + */ + override fun recycle() { + super.recycle() + archive.close() + pool.shutdown() + } + + /** + * Returns an observable containing the pages found on this rar archive ordered with a natural + * comparator. + */ + override fun getPages(): Observable> { + return archive.fileHeaders + .filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } + .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } + .mapIndexed { i, header -> + val streamFn = { getStream(header) } + + WatcherPage(i).apply { + stream = streamFn + status = Page.READY + } + } + .let { Observable.just(it) } + } + + /** + * Returns an observable that emits a ready state unless the loader was recycled. + */ + override fun getPage(page: WatcherPage): Observable { + return Observable.just( + if (isRecycled) { + Page.ERROR + } else { + Page.READY + } + ) + } + + /** + * Returns an input stream for the given [header]. + */ + private fun getStream(header: FileHeader): InputStream { + val pipeIn = PipedInputStream() + val pipeOut = PipedOutputStream(pipeIn) + pool.execute { + try { + pipeOut.use { + archive.extractFile(header, it) + } + } catch (e: Exception) { + } + } + return pipeIn + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/ZipPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/ZipPageLoader.kt new file mode 100644 index 000000000..d822fdcd2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/loader/ZipPageLoader.kt @@ -0,0 +1,65 @@ +package eu.kanade.tachiyomi.ui.watcher.loader + +import android.os.Build +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder +import eu.kanade.tachiyomi.util.system.ImageUtil +import rx.Observable +import java.io.File +import java.nio.charset.StandardCharsets +import java.util.zip.ZipFile + +/** + * Loader used to load a episode from a .zip or .cbz file. + */ +class ZipPageLoader(file: File) : PageLoader() { + + /** + * The zip file to load pages from. + */ + private val zip = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + ZipFile(file, StandardCharsets.ISO_8859_1) + } else { + ZipFile(file) + } + + /** + * Recycles this loader and the open zip. + */ + override fun recycle() { + super.recycle() + zip.close() + } + + /** + * Returns an observable containing the pages found on this zip archive ordered with a natural + * comparator. + */ + override fun getPages(): Observable> { + return zip.entries().toList() + .filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } + .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } + .mapIndexed { i, entry -> + val streamFn = { zip.getInputStream(entry) } + WatcherPage(i).apply { + stream = streamFn + status = Page.READY + } + } + .let { Observable.just(it) } + } + + /** + * Returns an observable that emits a ready state unless the loader was recycled. + */ + override fun getPage(page: WatcherPage): Observable { + return Observable.just( + if (isRecycled) { + Page.ERROR + } else { + Page.READY + } + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/EpisodeTransition.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/EpisodeTransition.kt new file mode 100644 index 000000000..f13408042 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/EpisodeTransition.kt @@ -0,0 +1,35 @@ +package eu.kanade.tachiyomi.ui.watcher.model + +sealed class EpisodeTransition { + + abstract val from: WatcherEpisode + abstract val to: WatcherEpisode? + + class Prev( + override val from: WatcherEpisode, + override val to: WatcherEpisode? + ) : EpisodeTransition() + + class Next( + override val from: WatcherEpisode, + override val to: WatcherEpisode? + ) : EpisodeTransition() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is EpisodeTransition) return false + if (from == other.from && to == other.to) return true + if (from == other.to && to == other.from) return true + return false + } + + override fun hashCode(): Int { + var result = from.hashCode() + result = 31 * result + (to?.hashCode() ?: 0) + return result + } + + override fun toString(): String { + return "${javaClass.simpleName}(from=${from.episode.url}, to=${to?.episode?.url})" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/InsertPage.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/InsertPage.kt new file mode 100644 index 000000000..9cefc2f4e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/InsertPage.kt @@ -0,0 +1,10 @@ +package eu.kanade.tachiyomi.ui.watcher.model + +class InsertPage(val parent: WatcherPage) : WatcherPage(parent.index, parent.url, parent.imageUrl) { + + override var episode: WatcherEpisode = parent.episode + + init { + stream = parent.stream + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/ViewerEpisodes.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/ViewerEpisodes.kt new file mode 100644 index 000000000..1fedf779e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/ViewerEpisodes.kt @@ -0,0 +1,20 @@ +package eu.kanade.tachiyomi.ui.watcher.model + +data class ViewerEpisodes( + val currEpisode: WatcherEpisode, + val prevEpisode: WatcherEpisode?, + val nextEpisode: WatcherEpisode? +) { + + fun ref() { + currEpisode.ref() + prevEpisode?.ref() + nextEpisode?.ref() + } + + fun unref() { + currEpisode.unref() + prevEpisode?.unref() + nextEpisode?.unref() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/WatcherEpisode.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/WatcherEpisode.kt new file mode 100644 index 000000000..8274df818 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/WatcherEpisode.kt @@ -0,0 +1,53 @@ +package eu.kanade.tachiyomi.ui.watcher.model + +import com.jakewharton.rxrelay.BehaviorRelay +import eu.kanade.tachiyomi.data.database.models.Episode +import eu.kanade.tachiyomi.ui.watcher.loader.PageLoader +import timber.log.Timber + +data class WatcherEpisode(val episode: Episode) { + + var state: State = + State.Wait + set(value) { + field = value + stateRelay.call(value) + } + + private val stateRelay by lazy { BehaviorRelay.create(state) } + + val stateObserver by lazy { stateRelay.asObservable() } + + val pages: List? + get() = (state as? State.Loaded)?.pages + + var pageLoader: PageLoader? = null + + var requestedPage: Int = 0 + + var references = 0 + private set + + fun ref() { + references++ + } + + fun unref() { + references-- + if (references == 0) { + if (pageLoader != null) { + Timber.d("Recycling episode ${episode.name}") + } + pageLoader?.recycle() + pageLoader = null + state = State.Wait + } + } + + sealed class State { + object Wait : State() + object Loading : State() + class Error(val error: Throwable) : State() + class Loaded(val pages: List) : State() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/WatcherPage.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/WatcherPage.kt new file mode 100644 index 000000000..468e868b3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/model/WatcherPage.kt @@ -0,0 +1,14 @@ +package eu.kanade.tachiyomi.ui.watcher.model + +import eu.kanade.tachiyomi.source.model.Page +import java.io.InputStream + +open class WatcherPage( + index: Int, + url: String = "", + imageUrl: String? = null, + var stream: (() -> InputStream)? = null +) : Page(index, url, imageUrl, null) { + + open lateinit var episode: WatcherEpisode +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/OrientationType.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/OrientationType.kt new file mode 100644 index 000000000..6e565f90e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/OrientationType.kt @@ -0,0 +1,43 @@ +package eu.kanade.tachiyomi.ui.watcher.setting + +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.content.res.Resources +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.lang.next + +enum class OrientationType(val prefValue: Int, val flag: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) { + FREE(1, ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, R.string.rotation_free, R.drawable.ic_screen_rotation_24dp), + LOCKED_PORTRAIT(2, ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT, R.string.rotation_lock, R.drawable.ic_screen_lock_rotation_24dp), + LOCKED_LANDSCAPE(2, ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, R.string.rotation_lock, R.drawable.ic_screen_lock_rotation_24dp), + PORTRAIT(3, ActivityInfo.SCREEN_ORIENTATION_PORTRAIT, R.string.rotation_force_portrait, R.drawable.ic_screen_lock_portrait_24dp), + LANDSCAPE(4, ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, R.string.rotation_force_landscape, R.drawable.ic_screen_lock_landscape_24dp); + + companion object { + fun fromPreference(preference: Int, resources: Resources): OrientationType = when (preference) { + 2 -> { + val currentOrientation = resources.configuration.orientation + if (currentOrientation == Configuration.ORIENTATION_PORTRAIT) { + LOCKED_PORTRAIT + } else { + LOCKED_LANDSCAPE + } + } + 3 -> PORTRAIT + 4 -> LANDSCAPE + else -> FREE + } + + fun getNextOrientation(preference: Int, resources: Resources): OrientationType { + val current = if (preference == 2) { + // Avoid issue due to 2 types having the same prefValue + LOCKED_LANDSCAPE + } else { + fromPreference(preference, resources) + } + return current.next() + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderColorFilterSettings.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderColorFilterSettings.kt new file mode 100644 index 000000000..d1ea14781 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderColorFilterSettings.kt @@ -0,0 +1,245 @@ +package eu.kanade.tachiyomi.ui.watcher.setting + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.SeekBar +import androidx.annotation.ColorInt +import androidx.core.graphics.alpha +import androidx.core.graphics.blue +import androidx.core.graphics.green +import androidx.core.graphics.red +import androidx.core.widget.NestedScrollView +import androidx.lifecycle.lifecycleScope +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.databinding.WatcherColorFilterSettingsBinding +import eu.kanade.tachiyomi.ui.watcher.WatcherActivity +import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener +import eu.kanade.tachiyomi.widget.SimpleSeekBarListener +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.sample +import uy.kohesive.injekt.injectLazy + +/** + * Color filter sheet to toggle custom filter and brightness overlay. + */ +class WatcherColorFilterSettings @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + NestedScrollView(context, attrs) { + + private val preferences: PreferencesHelper by injectLazy() + + private val binding = WatcherColorFilterSettingsBinding.inflate(LayoutInflater.from(context), this, false) + + init { + addView(binding.root) + + preferences.colorFilter().asFlow() + .onEach { setColorFilter(it) } + .launchIn((context as WatcherActivity).lifecycleScope) + + preferences.colorFilterMode().asFlow() + .onEach { setColorFilter(preferences.colorFilter().get()) } + .launchIn(context.lifecycleScope) + + preferences.customBrightness().asFlow() + .onEach { setCustomBrightness(it) } + .launchIn(context.lifecycleScope) + + // Get color and update values + val color = preferences.colorFilterValue().get() + val brightness = preferences.customBrightnessValue().get() + + val argb = setValues(color) + + // Set brightness value + binding.txtBrightnessSeekbarValue.text = brightness.toString() + binding.brightnessSeekbar.progress = brightness + + // Initialize seekBar progress + binding.seekbarColorFilterAlpha.progress = argb[0] + binding.seekbarColorFilterRed.progress = argb[1] + binding.seekbarColorFilterGreen.progress = argb[2] + binding.seekbarColorFilterBlue.progress = argb[3] + + // Set listeners + binding.switchColorFilter.isChecked = preferences.colorFilter().get() + binding.switchColorFilter.setOnCheckedChangeListener { _, isChecked -> + preferences.colorFilter().set(isChecked) + } + + binding.customBrightness.isChecked = preferences.customBrightness().get() + binding.customBrightness.setOnCheckedChangeListener { _, isChecked -> + preferences.customBrightness().set(isChecked) + } + + binding.colorFilterMode.onItemSelectedListener = IgnoreFirstSpinnerListener { position -> + preferences.colorFilterMode().set(position) + } + binding.colorFilterMode.setSelection(preferences.colorFilterMode().get(), false) + + binding.seekbarColorFilterAlpha.setOnSeekBarChangeListener( + object : SimpleSeekBarListener() { + override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { + if (fromUser) { + setColorValue(value, ALPHA_MASK, 24) + } + } + } + ) + + binding.seekbarColorFilterRed.setOnSeekBarChangeListener( + object : SimpleSeekBarListener() { + override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { + if (fromUser) { + setColorValue(value, RED_MASK, 16) + } + } + } + ) + + binding.seekbarColorFilterGreen.setOnSeekBarChangeListener( + object : SimpleSeekBarListener() { + override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { + if (fromUser) { + setColorValue(value, GREEN_MASK, 8) + } + } + } + ) + + binding.seekbarColorFilterBlue.setOnSeekBarChangeListener( + object : SimpleSeekBarListener() { + override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { + if (fromUser) { + setColorValue(value, BLUE_MASK, 0) + } + } + } + ) + + binding.brightnessSeekbar.setOnSeekBarChangeListener( + object : SimpleSeekBarListener() { + override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { + if (fromUser) { + preferences.customBrightnessValue().set(value) + } + } + } + ) + } + + /** + * Set enabled status of seekBars belonging to color filter + * @param enabled determines if seekBar gets enabled + */ + private fun setColorFilterSeekBar(enabled: Boolean) { + binding.seekbarColorFilterRed.isEnabled = enabled + binding.seekbarColorFilterGreen.isEnabled = enabled + binding.seekbarColorFilterBlue.isEnabled = enabled + binding.seekbarColorFilterAlpha.isEnabled = enabled + } + + /** + * Set enabled status of seekBars belonging to custom brightness + * @param enabled value which determines if seekBar gets enabled + */ + private fun setCustomBrightnessSeekBar(enabled: Boolean) { + binding.brightnessSeekbar.isEnabled = enabled + } + + /** + * Set the text value's of color filter + * @param color integer containing color information + */ + fun setValues(color: Int): Array { + val alpha = color.alpha + val red = color.red + val green = color.green + val blue = color.blue + + // Initialize values + binding.txtColorFilterAlphaValue.text = "$alpha" + binding.txtColorFilterRedValue.text = "$red" + binding.txtColorFilterGreenValue.text = "$green" + binding.txtColorFilterBlueValue.text = "$blue" + + return arrayOf(alpha, red, green, blue) + } + + /** + * Manages the custom brightness value subscription + * @param enabled determines if the subscription get (un)subscribed + */ + private fun setCustomBrightness(enabled: Boolean) { + if (enabled) { + preferences.customBrightnessValue().asFlow() + .sample(100) + .onEach { setCustomBrightnessValue(it) } + .launchIn((context as WatcherActivity).lifecycleScope) + } else { + setCustomBrightnessValue(0, true) + } + setCustomBrightnessSeekBar(enabled) + } + + /** + * Sets the brightness of the screen. Range is [-75, 100]. + * From -75 to -1 a semi-transparent black view is shown at the top with the minimum brightness. + * From 1 to 100 it sets that value as brightness. + * 0 sets system brightness and hides the overlay. + */ + private fun setCustomBrightnessValue(value: Int, isDisabled: Boolean = false) { + if (!isDisabled) { + binding.txtBrightnessSeekbarValue.text = value.toString() + } + } + + /** + * Manages the color filter value subscription + * @param enabled determines if the subscription get (un)subscribed + */ + private fun setColorFilter(enabled: Boolean) { + if (enabled) { + preferences.colorFilterValue().asFlow() + .sample(100) + .onEach { setColorFilterValue(it) } + .launchIn((context as WatcherActivity).lifecycleScope) + } + setColorFilterSeekBar(enabled) + } + + /** + * Sets the color filter overlay of the screen. Determined by HEX of integer + * @param color hex of color. + */ + private fun setColorFilterValue(@ColorInt color: Int) { + setValues(color) + } + + /** + * Updates the color value in preference + * @param color value of color range [0,255] + * @param mask contains hex mask of chosen color + * @param bitShift amounts of bits that gets shifted to receive value + */ + fun setColorValue(color: Int, mask: Long, bitShift: Int) { + val currentColor = preferences.colorFilterValue().get() + val updatedColor = (color shl bitShift) or (currentColor and mask.inv().toInt()) + preferences.colorFilterValue().set(updatedColor) + } + + private companion object { + /** Integer mask of alpha value **/ + const val ALPHA_MASK: Long = 0xFF000000 + + /** Integer mask of red value **/ + const val RED_MASK: Long = 0x00FF0000 + + /** Integer mask of green value **/ + const val GREEN_MASK: Long = 0x0000FF00 + + /** Integer mask of blue value **/ + const val BLUE_MASK: Long = 0x000000FF + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderGeneralSettings.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderGeneralSettings.kt new file mode 100644 index 000000000..03d62be93 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderGeneralSettings.kt @@ -0,0 +1,50 @@ +package eu.kanade.tachiyomi.ui.watcher.setting + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.core.view.isVisible +import androidx.core.widget.NestedScrollView +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.databinding.WatcherGeneralSettingsBinding +import eu.kanade.tachiyomi.ui.watcher.WatcherActivity +import eu.kanade.tachiyomi.util.preference.bindToPreference +import uy.kohesive.injekt.injectLazy + +/** + * Sheet to show watcher and viewer preferences. + */ +class WatcherGeneralSettings @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + NestedScrollView(context, attrs) { + + private val preferences: PreferencesHelper by injectLazy() + + private val binding = WatcherGeneralSettingsBinding.inflate(LayoutInflater.from(context), this, false) + + init { + addView(binding.root) + + initGeneralPreferences() + } + + /** + * Init general watcher preferences. + */ + private fun initGeneralPreferences() { + binding.rotationMode.bindToPreference(preferences.rotation(), 1) + binding.backgroundColor.bindToIntPreference(preferences.watcherTheme(), R.array.watcher_themes_values) + binding.showPageNumber.bindToPreference(preferences.showPageNumber()) + binding.fullscreen.bindToPreference(preferences.fullscreen()) + binding.keepscreen.bindToPreference(preferences.keepScreenOn()) + binding.longTap.bindToPreference(preferences.readWithLongTap()) + binding.alwaysShowEpisodeTransition.bindToPreference(preferences.alwaysShowEpisodeTransition()) + binding.pageTransitions.bindToPreference(preferences.pageTransitions()) + + // If the preference is explicitly disabled, that means the setting was configured since there is a cutout + if ((context as WatcherActivity).hasCutout || !preferences.cutoutShort().get()) { + binding.cutoutShort.isVisible = true + binding.cutoutShort.bindToPreference(preferences.cutoutShort()) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderReadingModeSettings.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderReadingModeSettings.kt new file mode 100644 index 000000000..3c5d5ec28 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderReadingModeSettings.kt @@ -0,0 +1,104 @@ +package eu.kanade.tachiyomi.ui.watcher.setting + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.core.view.isVisible +import androidx.core.widget.NestedScrollView +import androidx.lifecycle.lifecycleScope +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.asImmediateFlow +import eu.kanade.tachiyomi.databinding.WatcherReadingModeSettingsBinding +import eu.kanade.tachiyomi.ui.watcher.WatcherActivity +import eu.kanade.tachiyomi.ui.watcher.viewer.pager.PagerViewer +import eu.kanade.tachiyomi.ui.watcher.viewer.webtoon.WebtoonViewer +import eu.kanade.tachiyomi.util.preference.bindToPreference +import kotlinx.coroutines.flow.launchIn +import uy.kohesive.injekt.injectLazy + +/** + * Sheet to show watcher and viewer preferences. + */ +class WatcherReadingModeSettings @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + NestedScrollView(context, attrs) { + + private val preferences: PreferencesHelper by injectLazy() + + private val binding = WatcherReadingModeSettingsBinding.inflate(LayoutInflater.from(context), this, false) + + init { + addView(binding.root) + + initGeneralPreferences() + + when ((context as WatcherActivity).viewer) { + is PagerViewer -> initPagerPreferences() + is WebtoonViewer -> initWebtoonPreferences() + } + } + + /** + * Init general watcher preferences. + */ + private fun initGeneralPreferences() { + binding.viewer.onItemSelectedListener = { position -> + (context as WatcherActivity).presenter.setAnimeViewer(position) + + val animeViewer = (context as WatcherActivity).presenter.getAnimeViewer() + if (animeViewer == ReadingModeType.WEBTOON.prefValue || animeViewer == ReadingModeType.CONTINUOUS_VERTICAL.prefValue) { + initWebtoonPreferences() + } else { + initPagerPreferences() + } + } + binding.viewer.setSelection((context as WatcherActivity).presenter.anime?.viewer ?: 0) + } + + /** + * Init the preferences for the pager watcher. + */ + private fun initPagerPreferences() { + binding.webtoonPrefsGroup.root.isVisible = false + binding.pagerPrefsGroup.root.isVisible = true + + binding.pagerPrefsGroup.tappingPrefsGroup.isVisible = preferences.readWithTapping().get() + + binding.pagerPrefsGroup.tappingInverted.bindToPreference(preferences.pagerNavInverted()) + + binding.pagerPrefsGroup.pagerNav.bindToPreference(preferences.navigationModePager()) + binding.pagerPrefsGroup.scaleType.bindToPreference(preferences.imageScaleType(), 1) + binding.pagerPrefsGroup.zoomStart.bindToPreference(preferences.zoomStart(), 1) + binding.pagerPrefsGroup.cropBorders.bindToPreference(preferences.cropBorders()) + + // Makes so that dual page invert gets hidden away when turning of dual page split + binding.pagerPrefsGroup.dualPageSplit.bindToPreference(preferences.dualPageSplitPaged()) + preferences.dualPageSplitPaged() + .asImmediateFlow { binding.pagerPrefsGroup.dualPageInvert.isVisible = it } + .launchIn((context as WatcherActivity).lifecycleScope) + binding.pagerPrefsGroup.dualPageInvert.bindToPreference(preferences.dualPageInvertPaged()) + } + + /** + * Init the preferences for the webtoon watcher. + */ + private fun initWebtoonPreferences() { + binding.pagerPrefsGroup.root.isVisible = false + binding.webtoonPrefsGroup.root.isVisible = true + + binding.webtoonPrefsGroup.tappingPrefsGroup.isVisible = preferences.readWithTapping().get() + + binding.webtoonPrefsGroup.tappingInverted.bindToPreference(preferences.webtoonNavInverted()) + + binding.webtoonPrefsGroup.webtoonNav.bindToPreference(preferences.navigationModeWebtoon()) + binding.webtoonPrefsGroup.cropBordersWebtoon.bindToPreference(preferences.cropBordersWebtoon()) + binding.webtoonPrefsGroup.webtoonSidePadding.bindToIntPreference(preferences.webtoonSidePadding(), R.array.webtoon_side_padding_values) + + // Makes so that dual page invert gets hidden away when turning of dual page split + binding.webtoonPrefsGroup.dualPageSplit.bindToPreference(preferences.dualPageSplitWebtoon()) + preferences.dualPageSplitWebtoon() + .asImmediateFlow { binding.webtoonPrefsGroup.dualPageInvert.isVisible = it } + .launchIn((context as WatcherActivity).lifecycleScope) + binding.webtoonPrefsGroup.dualPageInvert.bindToPreference(preferences.dualPageInvertWebtoon()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderSettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderSettingsSheet.kt new file mode 100644 index 000000000..87bd25324 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReaderSettingsSheet.kt @@ -0,0 +1,63 @@ +package eu.kanade.tachiyomi.ui.watcher.setting + +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.tabs.TabLayout +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.watcher.WatcherActivity +import eu.kanade.tachiyomi.widget.SimpleTabSelectedListener +import eu.kanade.tachiyomi.widget.sheet.TabbedBottomSheetDialog + +class WatcherSettingsSheet( + private val activity: WatcherActivity, + showColorFilterSettings: Boolean = false, +) : TabbedBottomSheetDialog(activity) { + + private val readingModeSettings = WatcherReadingModeSettings(activity) + private val generalSettings = WatcherGeneralSettings(activity) + private val colorFilterSettings = WatcherColorFilterSettings(activity) + + private val sheetBackgroundDim = window?.attributes?.dimAmount ?: 0.25f + + init { + val sheetBehavior = BottomSheetBehavior.from(binding.root.parent as ViewGroup) + sheetBehavior.isFitToContents = false + sheetBehavior.halfExpandedRatio = 0.5f + + val filterTabIndex = getTabViews().indexOf(colorFilterSettings) + binding.tabs.addOnTabSelectedListener(object : SimpleTabSelectedListener() { + override fun onTabSelected(tab: TabLayout.Tab?) { + val isFilterTab = tab?.position == filterTabIndex + + // Remove dimmed backdrop so color filter changes can be previewed + window?.setDimAmount(if (isFilterTab) 0f else sheetBackgroundDim) + + // Hide toolbars + if (activity.menuVisible != !isFilterTab) { + activity.setMenuVisibility(!isFilterTab) + } + + // Partially collapse the sheet for better preview + if (isFilterTab) { + sheetBehavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED + } + } + }) + + if (showColorFilterSettings) { + binding.tabs.getTabAt(filterTabIndex)?.select() + } + } + + override fun getTabViews() = listOf( + readingModeSettings, + generalSettings, + colorFilterSettings, + ) + + override fun getTabTitles() = listOf( + R.string.pref_category_reading_mode, + R.string.pref_category_general, + R.string.custom_filter, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReadingModeType.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReadingModeType.kt new file mode 100644 index 000000000..77d72a9b2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/setting/ReadingModeType.kt @@ -0,0 +1,30 @@ +package eu.kanade.tachiyomi.ui.watcher.setting + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.lang.next + +enum class ReadingModeType(val prefValue: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) { + DEFAULT(0, R.string.default_viewer, R.drawable.ic_watcher_default_24dp), + LEFT_TO_RIGHT(1, R.string.left_to_right_viewer, R.drawable.ic_watcher_ltr_24dp), + RIGHT_TO_LEFT(2, R.string.right_to_left_viewer, R.drawable.ic_watcher_rtl_24dp), + VERTICAL(3, R.string.vertical_viewer, R.drawable.ic_watcher_vertical_24dp), + WEBTOON(4, R.string.webtoon_viewer, R.drawable.ic_watcher_webtoon_24dp), + CONTINUOUS_VERTICAL(5, R.string.vertical_plus_viewer, R.drawable.ic_watcher_continuous_vertical_24dp), + ; + + companion object { + fun fromPreference(preference: Int): ReadingModeType = values().find { it.prefValue == preference } ?: DEFAULT + + fun getNextReadingMode(preference: Int): ReadingModeType { + val current = fromPreference(preference) + return current.next() + } + + fun isPagerType(preference: Int): Boolean { + val mode = fromPreference(preference) + return mode == LEFT_TO_RIGHT || mode == RIGHT_TO_LEFT || mode == VERTICAL + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/BaseViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/BaseViewer.kt new file mode 100644 index 000000000..fa305d26c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/BaseViewer.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer + +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import eu.kanade.tachiyomi.ui.watcher.model.ViewerEpisodes + +/** + * Interface for implementing a viewer. + */ +interface BaseViewer { + + /** + * Returns the view this viewer uses. + */ + fun getView(): View + + /** + * Destroys this viewer. Called when leaving the watcher or swapping viewers. + */ + fun destroy() {} + + /** + * Tells this viewer to set the given [episodes] as active. + */ + fun setEpisodes(episodes: ViewerEpisodes) + + /** + * Tells this viewer to move to the given [page]. + */ + fun moveToPage(page: WatcherPage) + + /** + * Called from the containing activity when a key [event] is received. It should return true + * if the event was handled, false otherwise. + */ + fun handleKeyEvent(event: KeyEvent): Boolean + + /** + * Called from the containing activity when a generic motion [event] is received. It should + * return true if the event was handled, false otherwise. + */ + fun handleGenericMotionEvent(event: MotionEvent): Boolean +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/GestureDetectorWithLongTap.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/GestureDetectorWithLongTap.kt new file mode 100644 index 000000000..7a3367dc7 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/GestureDetectorWithLongTap.kt @@ -0,0 +1,74 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer + +import android.content.Context +import android.os.Handler +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.ViewConfiguration +import kotlin.math.abs + +/** + * A custom gesture detector that also implements an on long tap confirmed, because the built-in + * one conflicts with the quick scale feature. + */ +open class GestureDetectorWithLongTap( + context: Context, + listener: Listener +) : GestureDetector(context, listener) { + + private val handler = Handler() + private val slop = ViewConfiguration.get(context).scaledTouchSlop + private val longTapTime = ViewConfiguration.getLongPressTimeout().toLong() + private val doubleTapTime = ViewConfiguration.getDoubleTapTimeout().toLong() + + private var downX = 0f + private var downY = 0f + private var lastUp = 0L + private var lastDownEvent: MotionEvent? = null + + /** + * Runnable to execute when a long tap is confirmed. + */ + private val longTapFn = Runnable { listener.onLongTapConfirmed(lastDownEvent!!) } + + override fun onTouchEvent(ev: MotionEvent): Boolean { + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { + lastDownEvent?.recycle() + lastDownEvent = MotionEvent.obtain(ev) + + // This is the key difference with the built-in detector. We have to ignore the + // event if the last up and current down are too close in time (double tap). + if (ev.downTime - lastUp > doubleTapTime) { + downX = ev.rawX + downY = ev.rawY + handler.postDelayed(longTapFn, longTapTime) + } + } + MotionEvent.ACTION_MOVE -> { + if (abs(ev.rawX - downX) > slop || abs(ev.rawY - downY) > slop) { + handler.removeCallbacks(longTapFn) + } + } + MotionEvent.ACTION_UP -> { + lastUp = ev.eventTime + handler.removeCallbacks(longTapFn) + } + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_DOWN -> { + handler.removeCallbacks(longTapFn) + } + } + return super.onTouchEvent(ev) + } + + /** + * Custom listener to also include a long tap confirmed + */ + open class Listener : SimpleOnGestureListener() { + /** + * Notified when a long tap occurs with the initial on down [ev] that triggered it. + */ + open fun onLongTapConfirmed(ev: MotionEvent) { + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/MissingChapters.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/MissingChapters.kt new file mode 100644 index 000000000..99dd5855f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/MissingChapters.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer + +import eu.kanade.tachiyomi.data.database.models.Episode +import eu.kanade.tachiyomi.ui.watcher.model.WatcherEpisode +import kotlin.math.floor + +private val pattern = Regex("""\d+""") + +fun hasMissingEpisodes(higherWatcherEpisode: WatcherEpisode?, lowerWatcherEpisode: WatcherEpisode?): Boolean { + if (higherWatcherEpisode == null || lowerWatcherEpisode == null) return false + return hasMissingEpisodes(higherWatcherEpisode.episode, lowerWatcherEpisode.episode) +} + +fun hasMissingEpisodes(higherEpisode: Episode?, lowerEpisode: Episode?): Boolean { + if (higherEpisode == null || lowerEpisode == null) return false + // Check if name contains a number that is potential episode number + if (!pattern.containsMatchIn(higherEpisode.name) || !pattern.containsMatchIn(lowerEpisode.name)) return false + // Check if potential episode number was recognized as episode number + if (!higherEpisode.isRecognizedNumber || !lowerEpisode.isRecognizedNumber) return false + return hasMissingEpisodes(higherEpisode.episode_number, lowerEpisode.episode_number) +} + +fun hasMissingEpisodes(higherEpisodeNumber: Float, lowerEpisodeNumber: Float): Boolean { + if (higherEpisodeNumber < 0f || lowerEpisodeNumber < 0f) return false + return calculateEpisodeDifference(higherEpisodeNumber, lowerEpisodeNumber) > 0f +} + +fun calculateEpisodeDifference(higherWatcherEpisode: WatcherEpisode?, lowerWatcherEpisode: WatcherEpisode?): Float { + if (higherWatcherEpisode == null || lowerWatcherEpisode == null) return 0f + return calculateEpisodeDifference(higherWatcherEpisode.episode, lowerWatcherEpisode.episode) +} + +fun calculateEpisodeDifference(higherEpisode: Episode?, lowerEpisode: Episode?): Float { + if (higherEpisode == null || lowerEpisode == null) return 0f + // Check if name contains a number that is potential episode number + if (!pattern.containsMatchIn(higherEpisode.name) || !pattern.containsMatchIn(lowerEpisode.name)) return 0f + // Check if potential episode number was recognized as episode number + if (!higherEpisode.isRecognizedNumber || !lowerEpisode.isRecognizedNumber) return 0f + return calculateEpisodeDifference(higherEpisode.episode_number, lowerEpisode.episode_number) +} + +fun calculateEpisodeDifference(higherEpisodeNumber: Float, lowerEpisodeNumber: Float): Float { + if (higherEpisodeNumber < 0f || lowerEpisodeNumber < 0f) return 0f + return floor(higherEpisodeNumber) - floor(lowerEpisodeNumber) - 1f +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ReaderProgressBar.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ReaderProgressBar.kt new file mode 100644 index 000000000..443f454b8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ReaderProgressBar.kt @@ -0,0 +1,215 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer + +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.util.AttributeSet +import android.view.View +import android.view.animation.Animation +import android.view.animation.DecelerateInterpolator +import android.view.animation.LinearInterpolator +import android.view.animation.RotateAnimation +import androidx.core.animation.doOnCancel +import androidx.core.animation.doOnEnd +import androidx.core.view.isGone +import androidx.core.view.isVisible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.system.getResourceColor +import kotlin.math.min + +/** + * A custom progress bar that always rotates while being determinate. By always rotating we give + * the feedback to the user that the application isn't 'stuck', and by making it determinate the + * user also approximately knows how much the operation will take. + */ +class WatcherProgressBar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + /** + * The current sweep angle. It always starts at 10% because otherwise the bar and the rotation + * wouldn't be visible. + */ + private var sweepAngle = 10f + + /** + * Whether the parent views are also visible. + */ + private var aggregatedIsVisible = false + + /** + * The paint to use to draw the progress bar. + */ + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = context.getResourceColor(R.attr.colorAccent) + isAntiAlias = true + strokeCap = Paint.Cap.ROUND + style = Paint.Style.STROKE + } + + /** + * The rectangle of the canvas where the progress bar should be drawn. This is calculated on + * layout. + */ + private val ovalRect = RectF() + + /** + * The rotation animation to use while the progress bar is visible. + */ + private val rotationAnimation by lazy { + RotateAnimation( + 0f, + 360f, + Animation.RELATIVE_TO_SELF, + 0.5f, + Animation.RELATIVE_TO_SELF, + 0.5f + ).apply { + interpolator = LinearInterpolator() + repeatCount = Animation.INFINITE + duration = 4000 + } + } + + /** + * Called when the view is layout. The position and thickness of the progress bar is calculated. + */ + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + + val diameter = min(width, height) + val thickness = diameter / 10f + val pad = thickness / 2f + ovalRect.set(pad, pad, diameter - pad, diameter - pad) + + paint.strokeWidth = thickness + } + + /** + * Called when the view is being drawn. An arc is drawn with the calculated rectangle. The + * animation will take care of rotation. + */ + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawArc(ovalRect, -90f, sweepAngle, false, paint) + } + + /** + * Calculates the sweep angle to use from the progress. + */ + private fun calcSweepAngleFromProgress(progress: Int): Float { + return 360f / 100 * progress + } + + /** + * Called when this view is attached to window. It starts the rotation animation. + */ + override fun onAttachedToWindow() { + super.onAttachedToWindow() + startAnimation() + } + + /** + * Called when this view is detached to window. It stops the rotation animation. + */ + override fun onDetachedFromWindow() { + stopAnimation() + super.onDetachedFromWindow() + } + + /** + * Called when the visibility of this view changes. + */ + override fun setVisibility(visibility: Int) { + super.setVisibility(visibility) + val isVisible = visibility == VISIBLE + if (isVisible) { + startAnimation() + } else { + stopAnimation() + } + } + + /** + * Starts the rotation animation if needed. + */ + private fun startAnimation() { + if (visibility != VISIBLE || windowVisibility != VISIBLE || animation != null) { + return + } + + animation = rotationAnimation + animation.start() + } + + /** + * Stops the rotation animation if needed. + */ + private fun stopAnimation() { + clearAnimation() + } + + /** + * Hides this progress bar with an optional fade out if [animate] is true. + */ + fun hide(animate: Boolean = false) { + if (isGone) return + + if (!animate) { + isVisible = false + } else { + ObjectAnimator.ofFloat(this, "alpha", 1f, 0f).apply { + interpolator = DecelerateInterpolator() + duration = 1000 + doOnEnd { + isVisible = false + alpha = 1f + } + doOnCancel { + alpha = 1f + } + start() + } + } + } + + /** + * Completes this progress bar and fades out the view. + */ + fun completeAndFadeOut() { + setRealProgress(100) + hide(true) + } + + /** + * Set progress of the circular progress bar ensuring a min max range in order to notice the + * rotation animation. + */ + fun setProgress(progress: Int) { + // Scale progress in [10, 95] range + val scaledProgress = 85 * progress / 100 + 10 + setRealProgress(scaledProgress) + } + + /** + * Sets the real progress of the circular progress bar. Note that if this progres is 0 or + * 100, the rotation animation won't be noticed by the user because nothing changes in the + * canvas. + */ + private fun setRealProgress(progress: Int) { + ValueAnimator.ofFloat(sweepAngle, calcSweepAngleFromProgress(progress)).apply { + interpolator = DecelerateInterpolator() + duration = 250 + addUpdateListener { valueAnimator -> + sweepAngle = valueAnimator.animatedValue as Float + invalidate() + } + start() + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ReaderTransitionView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ReaderTransitionView.kt new file mode 100644 index 000000000..10ef16788 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ReaderTransitionView.kt @@ -0,0 +1,105 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import androidx.core.text.bold +import androidx.core.text.buildSpannedString +import androidx.core.view.isVisible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.WatcherTransitionViewBinding +import eu.kanade.tachiyomi.ui.watcher.model.EpisodeTransition + +class WatcherTransitionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + LinearLayout(context, attrs) { + + private val binding: WatcherTransitionViewBinding + + init { + binding = WatcherTransitionViewBinding.inflate(LayoutInflater.from(context), this, true) + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + + fun bind(transition: EpisodeTransition) { + when (transition) { + is EpisodeTransition.Prev -> bindPrevEpisodeTransition(transition) + is EpisodeTransition.Next -> bindNextEpisodeTransition(transition) + } + + missingEpisodeWarning(transition) + } + + /** + * Binds a previous episode transition on this view and subscribes to the page load status. + */ + private fun bindPrevEpisodeTransition(transition: EpisodeTransition) { + val prevEpisode = transition.to + + val hasPrevEpisode = prevEpisode != null + binding.lowerText.isVisible = hasPrevEpisode + if (hasPrevEpisode) { + binding.upperText.textAlignment = TEXT_ALIGNMENT_TEXT_START + binding.upperText.text = buildSpannedString { + bold { append(context.getString(R.string.transition_previous)) } + append("\n${prevEpisode!!.episode.name}") + } + binding.lowerText.text = buildSpannedString { + bold { append(context.getString(R.string.transition_current)) } + append("\n${transition.from.episode.name}") + } + } else { + binding.upperText.textAlignment = TEXT_ALIGNMENT_CENTER + binding.upperText.text = context.getString(R.string.transition_no_previous) + } + } + + /** + * Binds a next episode transition on this view and subscribes to the load status. + */ + private fun bindNextEpisodeTransition(transition: EpisodeTransition) { + val nextEpisode = transition.to + + val hasNextEpisode = nextEpisode != null + binding.lowerText.isVisible = hasNextEpisode + if (hasNextEpisode) { + binding.upperText.textAlignment = TEXT_ALIGNMENT_TEXT_START + binding.upperText.text = buildSpannedString { + bold { append(context.getString(R.string.transition_finished)) } + append("\n${transition.from.episode.name}") + } + binding.lowerText.text = buildSpannedString { + bold { append(context.getString(R.string.transition_next)) } + append("\n${nextEpisode!!.episode.name}") + } + } else { + binding.upperText.textAlignment = TEXT_ALIGNMENT_CENTER + binding.upperText.text = context.getString(R.string.transition_no_next) + } + } + + private fun missingEpisodeWarning(transition: EpisodeTransition) { + if (transition.to == null) { + binding.warning.isVisible = false + return + } + + val hasMissingEpisodes = when (transition) { + is EpisodeTransition.Prev -> hasMissingEpisodes(transition.from, transition.to) + is EpisodeTransition.Next -> hasMissingEpisodes(transition.to, transition.from) + } + + if (!hasMissingEpisodes) { + binding.warning.isVisible = false + return + } + + val episodeDifference = when (transition) { + is EpisodeTransition.Prev -> calculateEpisodeDifference(transition.from, transition.to) + is EpisodeTransition.Next -> calculateEpisodeDifference(transition.to, transition.from) + } + + binding.warningText.text = resources.getQuantityString(R.plurals.missing_episodes_warning, episodeDifference.toInt(), episodeDifference.toInt()) + binding.warning.isVisible = true + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ViewerConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ViewerConfig.kt new file mode 100644 index 000000000..5924ff5a8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ViewerConfig.kt @@ -0,0 +1,93 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer + +import com.tfcporciuncula.flow.Preference +import eu.kanade.tachiyomi.data.preference.PreferenceValues.TappingInvertMode +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Common configuration for all viewers. + */ +abstract class ViewerConfig(preferences: PreferencesHelper, private val scope: CoroutineScope) { + + var imagePropertyChangedListener: (() -> Unit)? = null + + var navigationModeChangedListener: (() -> Unit)? = null + + var tappingEnabled = true + var tappingInverted = TappingInvertMode.NONE + var longTapEnabled = true + var usePageTransitions = false + var doubleTapAnimDuration = 500 + var volumeKeysEnabled = false + var volumeKeysInverted = false + var trueColor = false + var alwaysShowEpisodeTransition = true + var navigationMode = 0 + protected set + + var forceNavigationOverlay = false + + var navigationOverlayOnStart = false + + var dualPageSplit = false + protected set + + var dualPageInvert = false + protected set + + abstract var navigator: ViewerNavigation + protected set + + init { + preferences.readWithTapping() + .register({ tappingEnabled = it }) + + preferences.readWithLongTap() + .register({ longTapEnabled = it }) + + preferences.pageTransitions() + .register({ usePageTransitions = it }) + + preferences.doubleTapAnimSpeed() + .register({ doubleTapAnimDuration = it }) + + preferences.readWithVolumeKeys() + .register({ volumeKeysEnabled = it }) + + preferences.readWithVolumeKeysInverted() + .register({ volumeKeysInverted = it }) + + preferences.trueColor() + .register({ trueColor = it }, { imagePropertyChangedListener?.invoke() }) + + preferences.alwaysShowEpisodeTransition() + .register({ alwaysShowEpisodeTransition = it }) + + forceNavigationOverlay = preferences.showNavigationOverlayNewUser().get() + if (forceNavigationOverlay) { + preferences.showNavigationOverlayNewUser().set(false) + } + + preferences.showNavigationOverlayOnStart() + .register({ navigationOverlayOnStart = it }) + } + + protected abstract fun defaultNavigation(): ViewerNavigation + + abstract fun updateNavigation(navigationMode: Int) + + fun Preference.register( + valueAssignment: (T) -> Unit, + onChanged: (T) -> Unit = {} + ) { + asFlow() + .onEach { valueAssignment(it) } + .distinctUntilChanged() + .onEach { onChanged(it) } + .launchIn(scope) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ViewerNavigation.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ViewerNavigation.kt new file mode 100644 index 000000000..b0bbffa3c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/ViewerNavigation.kt @@ -0,0 +1,49 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer + +import android.graphics.PointF +import android.graphics.RectF +import androidx.annotation.StringRes +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferenceValues +import eu.kanade.tachiyomi.util.lang.invert + +abstract class ViewerNavigation { + + sealed class NavigationRegion(@StringRes val nameRes: Int, val colorRes: Int) { + object MENU : NavigationRegion(R.string.action_menu, R.color.navigation_menu) + object PREV : NavigationRegion(R.string.nav_zone_prev, R.color.navigation_prev) + object NEXT : NavigationRegion(R.string.nav_zone_next, R.color.navigation_next) + object LEFT : NavigationRegion(R.string.nav_zone_left, R.color.navigation_left) + object RIGHT : NavigationRegion(R.string.nav_zone_right, R.color.navigation_right) + } + + data class Region( + val rectF: RectF, + val type: NavigationRegion + ) { + fun invert(invertMode: PreferenceValues.TappingInvertMode): Region { + if (invertMode == PreferenceValues.TappingInvertMode.NONE) return this + return this.copy( + rectF = this.rectF.invert(invertMode) + ) + } + } + + private var constantMenuRegion: RectF = RectF(0f, 0f, 1f, 0.05f) + + abstract var regions: List + + var invertMode: PreferenceValues.TappingInvertMode = PreferenceValues.TappingInvertMode.NONE + + fun getAction(pos: PointF): NavigationRegion { + val x = pos.x + val y = pos.y + val region = regions.map { it.invert(invertMode) } + .find { it.rectF.contains(x, y) } + return when { + region != null -> region.type + constantMenuRegion.contains(x, y) -> NavigationRegion.MENU + else -> NavigationRegion.MENU + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/EdgeNavigation.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/EdgeNavigation.kt new file mode 100644 index 000000000..8c26340f1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/EdgeNavigation.kt @@ -0,0 +1,32 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.navigation + +import android.graphics.RectF +import eu.kanade.tachiyomi.ui.watcher.viewer.ViewerNavigation + +/** + * Visualization of default state without any inversion + * +---+---+---+ + * | N | N | N | P: Previous + * +---+---+---+ + * | N | M | N | M: Menu + * +---+---+---+ + * | N | P | N | N: Next + * +---+---+---+ +*/ +class EdgeNavigation : ViewerNavigation() { + + override var regions: List = listOf( + Region( + rectF = RectF(0f, 0f, 0.33f, 1f), + type = NavigationRegion.NEXT + ), + Region( + rectF = RectF(0.33f, 0.66f, 0.66f, 1f), + type = NavigationRegion.PREV + ), + Region( + rectF = RectF(0.66f, 0f, 1f, 1f), + type = NavigationRegion.NEXT + ), + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/KindlishNavigation.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/KindlishNavigation.kt new file mode 100644 index 000000000..2a699ee33 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/KindlishNavigation.kt @@ -0,0 +1,28 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.navigation + +import android.graphics.RectF +import eu.kanade.tachiyomi.ui.watcher.viewer.ViewerNavigation + +/** + * Visualization of default state without any inversion + * +---+---+---+ + * | M | M | M | P: Previous + * +---+---+---+ + * | P | N | N | M: Menu + * +---+---+---+ + * | P | N | N | N: Next + * +---+---+---+ +*/ +class KindlishNavigation : ViewerNavigation() { + + override var regions: List = listOf( + Region( + rectF = RectF(0.33f, 0.33f, 1f, 1f), + type = NavigationRegion.NEXT + ), + Region( + rectF = RectF(0f, 0.33f, 0.33f, 1f), + type = NavigationRegion.PREV + ) + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/LNavigation.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/LNavigation.kt new file mode 100644 index 000000000..05cf650db --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/LNavigation.kt @@ -0,0 +1,36 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.navigation + +import android.graphics.RectF +import eu.kanade.tachiyomi.ui.watcher.viewer.ViewerNavigation + +/** + * Visualization of default state without any inversion + * +---+---+---+ + * | P | P | P | P: Previous + * +---+---+---+ + * | P | M | N | M: Menu + * +---+---+---+ + * | N | N | N | N: Next + * +---+---+---+ + */ +open class LNavigation : ViewerNavigation() { + + override var regions: List = listOf( + Region( + rectF = RectF(0f, 0.33f, 0.33f, 0.66f), + type = NavigationRegion.PREV + ), + Region( + rectF = RectF(0f, 0f, 1f, 0.33f), + type = NavigationRegion.PREV + ), + Region( + rectF = RectF(0.66f, 0.33f, 1f, 0.66f), + type = NavigationRegion.NEXT + ), + Region( + rectF = RectF(0f, 0.66f, 1f, 1f), + type = NavigationRegion.NEXT + ) + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/RightAndLeftNavigation.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/RightAndLeftNavigation.kt new file mode 100644 index 000000000..ef15864f7 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/navigation/RightAndLeftNavigation.kt @@ -0,0 +1,28 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.navigation + +import android.graphics.RectF +import eu.kanade.tachiyomi.ui.watcher.viewer.ViewerNavigation + +/** + * Visualization of default state without any inversion + * +---+---+---+ + * | N | M | P | P: Move Right + * +---+---+---+ + * | N | M | P | M: Menu + * +---+---+---+ + * | N | M | P | N: Move Left + * +---+---+---+ + */ +class RightAndLeftNavigation : ViewerNavigation() { + + override var regions: List = listOf( + Region( + rectF = RectF(0f, 0f, 0.33f, 1f), + type = NavigationRegion.LEFT + ), + Region( + rectF = RectF(0.66f, 0f, 1f, 1f), + type = NavigationRegion.RIGHT + ), + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/Pager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/Pager.kt new file mode 100644 index 000000000..7209d98a2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/Pager.kt @@ -0,0 +1,109 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.pager + +import android.content.Context +import android.view.HapticFeedbackConstants +import android.view.KeyEvent +import android.view.MotionEvent +import androidx.viewpager.widget.DirectionalViewPager +import eu.kanade.tachiyomi.ui.watcher.viewer.GestureDetectorWithLongTap + +/** + * Pager implementation that listens for tap and long tap and allows temporarily disabling touch + * events in order to work with child views that need to disable touch events on this parent. The + * pager can also be declared to be vertical by creating it with [isHorizontal] to false. + */ +open class Pager( + context: Context, + isHorizontal: Boolean = true +) : DirectionalViewPager(context, isHorizontal) { + + /** + * Tap listener function to execute when a tap is detected. + */ + var tapListener: ((MotionEvent) -> Unit)? = null + + /** + * Long tap listener function to execute when a long tap is detected. + */ + var longTapListener: ((MotionEvent) -> Boolean)? = null + + /** + * Gesture listener that implements tap and long tap events. + */ + private val gestureListener = object : GestureDetectorWithLongTap.Listener() { + override fun onSingleTapConfirmed(ev: MotionEvent): Boolean { + tapListener?.invoke(ev) + return true + } + + override fun onLongTapConfirmed(ev: MotionEvent) { + val listener = longTapListener + if (listener != null && listener.invoke(ev)) { + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + } + } + + /** + * Gesture detector which handles motion events. + */ + private val gestureDetector = GestureDetectorWithLongTap(context, gestureListener) + + /** + * Whether the gesture detector is currently enabled. + */ + private var isGestureDetectorEnabled = true + + /** + * Dispatches a touch event. + */ + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + val handled = super.dispatchTouchEvent(ev) + if (isGestureDetectorEnabled) { + gestureDetector.onTouchEvent(ev) + } + return handled + } + + /** + * Whether the given [ev] should be intercepted. Only used to prevent crashes when child + * views manipulate [requestDisallowInterceptTouchEvent]. + */ + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + return try { + super.onInterceptTouchEvent(ev) + } catch (e: IllegalArgumentException) { + false + } + } + + /** + * Handles a touch event. Only used to prevent crashes when child views manipulate + * [requestDisallowInterceptTouchEvent]. + */ + override fun onTouchEvent(ev: MotionEvent): Boolean { + return try { + super.onTouchEvent(ev) + } catch (e: IndexOutOfBoundsException) { + false + } catch (e: IllegalArgumentException) { + false + } + } + + /** + * Executes the given key event when this pager has focus. Just do nothing because the watcher + * already dispatches key events to the viewer and has more control than this method. + */ + override fun executeKeyEvent(event: KeyEvent): Boolean { + // Disable viewpager's default key event handling + return false + } + + /** + * Enables or disables the gesture detector. + */ + fun setGestureDetectorEnabled(enabled: Boolean) { + isGestureDetectorEnabled = enabled + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerButton.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerButton.kt new file mode 100644 index 000000000..de0d2a754 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerButton.kt @@ -0,0 +1,24 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.pager + +import android.annotation.SuppressLint +import android.content.Context +import android.view.MotionEvent +import androidx.appcompat.widget.AppCompatButton + +/** + * A button class to be used by child views of the pager viewer. All tap gestures are handled by + * the pager, but this class disables that behavior to allow clickable buttons. + */ +@SuppressLint("ViewConstructor") +class PagerButton(context: Context, viewer: PagerViewer) : AppCompatButton(context) { + + init { + setOnTouchListener { _, event -> + viewer.pager.setGestureDetectorEnabled(false) + if (event.actionMasked == MotionEvent.ACTION_UP) { + viewer.pager.setGestureDetectorEnabled(true) + } + false + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerConfig.kt new file mode 100644 index 000000000..23f14395d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerConfig.kt @@ -0,0 +1,114 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.pager + +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.watcher.viewer.ViewerConfig +import eu.kanade.tachiyomi.ui.watcher.viewer.ViewerNavigation +import eu.kanade.tachiyomi.ui.watcher.viewer.navigation.EdgeNavigation +import eu.kanade.tachiyomi.ui.watcher.viewer.navigation.KindlishNavigation +import eu.kanade.tachiyomi.ui.watcher.viewer.navigation.LNavigation +import eu.kanade.tachiyomi.ui.watcher.viewer.navigation.RightAndLeftNavigation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * Configuration used by pager viewers. + */ +class PagerConfig( + private val viewer: PagerViewer, + scope: CoroutineScope, + preferences: PreferencesHelper = Injekt.get() +) : ViewerConfig(preferences, scope) { + + var dualPageSplitChangedListener: ((Boolean) -> Unit)? = null + + var imageScaleType = 1 + private set + + var imageZoomType = ZoomType.Left + private set + + var imageCropBorders = false + private set + + init { + preferences.imageScaleType() + .register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() }) + + preferences.zoomStart() + .register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() }) + + preferences.cropBorders() + .register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() }) + + preferences.navigationModePager() + .register({ navigationMode = it }, { updateNavigation(navigationMode) }) + + preferences.pagerNavInverted() + .register({ tappingInverted = it }, { navigator.invertMode = it }) + preferences.pagerNavInverted().asFlow() + .drop(1) + .onEach { navigationModeChangedListener?.invoke() } + .launchIn(scope) + + preferences.dualPageSplitPaged() + .register( + { dualPageSplit = it }, + { + imagePropertyChangedListener?.invoke() + dualPageSplitChangedListener?.invoke(it) + } + ) + + preferences.dualPageInvertPaged() + .register({ dualPageInvert = it }, { imagePropertyChangedListener?.invoke() }) + } + + private fun zoomTypeFromPreference(value: Int) { + imageZoomType = when (value) { + // Auto + 1 -> when (viewer) { + is L2RPagerViewer -> ZoomType.Left + is R2LPagerViewer -> ZoomType.Right + else -> ZoomType.Center + } + // Left + 2 -> ZoomType.Left + // Right + 3 -> ZoomType.Right + // Center + else -> ZoomType.Center + } + } + + override var navigator: ViewerNavigation = defaultNavigation() + set(value) { + field = value.also { it.invertMode = this.tappingInverted } + } + + override fun defaultNavigation(): ViewerNavigation { + return when (viewer) { + is VerticalPagerViewer -> LNavigation() + else -> RightAndLeftNavigation() + } + } + + override fun updateNavigation(navigationMode: Int) { + navigator = when (navigationMode) { + 0 -> defaultNavigation() + 1 -> LNavigation() + 2 -> KindlishNavigation() + 3 -> EdgeNavigation() + 4 -> RightAndLeftNavigation() + else -> defaultNavigation() + } + navigationModeChangedListener?.invoke() + } + + enum class ZoomType { + Left, Center, Right + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerPageHolder.kt new file mode 100644 index 000000000..8fdc83c7d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerPageHolder.kt @@ -0,0 +1,514 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.pager + +import android.annotation.SuppressLint +import android.graphics.PointF +import android.graphics.drawable.Drawable +import android.view.GestureDetector +import android.view.Gravity +import android.view.MotionEvent +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.isVisible +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.bumptech.glide.load.resource.gif.GifDrawable +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.bumptech.glide.request.transition.NoTransition +import com.davemorrissey.labs.subscaleview.ImageSource +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import com.github.chrisbanes.photoview.PhotoView +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.ui.watcher.model.InsertPage +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import eu.kanade.tachiyomi.ui.watcher.viewer.WatcherProgressBar +import eu.kanade.tachiyomi.ui.watcher.viewer.pager.PagerConfig.ZoomType +import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import eu.kanade.tachiyomi.util.system.ImageUtil +import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.widget.ViewPagerAdapter +import rx.Observable +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import java.io.InputStream +import java.util.concurrent.TimeUnit + +/** + * View of the ViewPager that contains a page of a episode. + */ +@SuppressLint("ViewConstructor") +class PagerPageHolder( + val viewer: PagerViewer, + val page: WatcherPage +) : FrameLayout(viewer.activity), ViewPagerAdapter.PositionableView { + + /** + * Item that identifies this view. Needed by the adapter to not recreate views. + */ + override val item + get() = page + + /** + * Loading progress bar to indicate the current progress. + */ + private val progressBar = createProgressBar() + + /** + * Image view that supports subsampling on zoom. + */ + private var subsamplingImageView: SubsamplingScaleImageView? = null + + /** + * Simple image view only used on GIFs. + */ + private var imageView: ImageView? = null + + /** + * Retry button used to allow retrying. + */ + private var retryButton: PagerButton? = null + + /** + * Error layout to show when the image fails to decode. + */ + private var decodeErrorLayout: ViewGroup? = null + + /** + * Subscription for status changes of the page. + */ + private var statusSubscription: Subscription? = null + + /** + * Subscription for progress changes of the page. + */ + private var progressSubscription: Subscription? = null + + /** + * Subscription used to read the header of the image. This is needed in order to instantiate + * the appropiate image view depending if the image is animated (GIF). + */ + private var readImageHeaderSubscription: Subscription? = null + + init { + addView(progressBar) + observeStatus() + } + + /** + * Called when this view is detached from the window. Unsubscribes any active subscription. + */ + @SuppressLint("ClickableViewAccessibility") + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + unsubscribeProgress() + unsubscribeStatus() + unsubscribeReadImageHeader() + subsamplingImageView?.setOnImageEventListener(null) + } + + /** + * Observes the status of the page and notify the changes. + * + * @see processStatus + */ + private fun observeStatus() { + statusSubscription?.unsubscribe() + + val loader = page.episode.pageLoader ?: return + statusSubscription = loader.getPage(page) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { processStatus(it) } + } + + /** + * Observes the progress of the page and updates view. + */ + private fun observeProgress() { + progressSubscription?.unsubscribe() + + progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS) + .map { page.progress } + .distinctUntilChanged() + .onBackpressureLatest() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { value -> progressBar.setProgress(value) } + } + + /** + * Called when the status of the page changes. + * + * @param status the new status of the page. + */ + private fun processStatus(status: Int) { + when (status) { + Page.QUEUE -> setQueued() + Page.LOAD_PAGE -> setLoading() + Page.DOWNLOAD_IMAGE -> { + observeProgress() + setDownloading() + } + Page.READY -> { + setImage() + unsubscribeProgress() + } + Page.ERROR -> { + setError() + unsubscribeProgress() + } + } + } + + /** + * Unsubscribes from the status subscription. + */ + private fun unsubscribeStatus() { + statusSubscription?.unsubscribe() + statusSubscription = null + } + + /** + * Unsubscribes from the progress subscription. + */ + private fun unsubscribeProgress() { + progressSubscription?.unsubscribe() + progressSubscription = null + } + + /** + * Unsubscribes from the read image header subscription. + */ + private fun unsubscribeReadImageHeader() { + readImageHeaderSubscription?.unsubscribe() + readImageHeaderSubscription = null + } + + /** + * Called when the page is queued. + */ + private fun setQueued() { + progressBar.isVisible = true + retryButton?.isVisible = false + decodeErrorLayout?.isVisible = false + } + + /** + * Called when the page is loading. + */ + private fun setLoading() { + progressBar.isVisible = true + retryButton?.isVisible = false + decodeErrorLayout?.isVisible = false + } + + /** + * Called when the page is downloading. + */ + private fun setDownloading() { + progressBar.isVisible = true + retryButton?.isVisible = false + decodeErrorLayout?.isVisible = false + } + + /** + * Called when the page is ready. + */ + private fun setImage() { + progressBar.isVisible = true + progressBar.completeAndFadeOut() + retryButton?.isVisible = false + decodeErrorLayout?.isVisible = false + + unsubscribeReadImageHeader() + val streamFn = page.stream ?: return + + var openStream: InputStream? = null + readImageHeaderSubscription = Observable + .fromCallable { + val stream = streamFn().buffered(16) + openStream = stream + + ImageUtil.findImageType(stream) == ImageUtil.ImageType.GIF + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { isAnimated -> + if (viewer.config.dualPageSplit) { + openStream = processDualPageSplit(openStream!!) + } + if (!isAnimated) { + initSubsamplingImageView().setImage(ImageSource.inputStream(openStream!!)) + } else { + initImageView().setImage(openStream!!) + } + } + // Keep the Rx stream alive to close the input stream only when unsubscribed + .flatMap { Observable.never() } + .doOnUnsubscribe { openStream?.close() } + .subscribe({}, {}) + } + + private fun processDualPageSplit(openStream: InputStream): InputStream { + var inputStream = openStream + val (isDoublePage, stream) = when (page) { + is InsertPage -> Pair(true, inputStream) + else -> ImageUtil.isDoublePage(inputStream) + } + inputStream = stream + + if (!isDoublePage) return inputStream + + var side = when { + viewer is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.RIGHT + (viewer is R2LPagerViewer || viewer is VerticalPagerViewer) && page is InsertPage -> ImageUtil.Side.LEFT + viewer is L2RPagerViewer && page !is InsertPage -> ImageUtil.Side.LEFT + (viewer is R2LPagerViewer || viewer is VerticalPagerViewer) && page !is InsertPage -> ImageUtil.Side.RIGHT + else -> error("We should choose a side!") + } + + if (viewer.config.dualPageInvert) { + side = when (side) { + ImageUtil.Side.RIGHT -> ImageUtil.Side.LEFT + ImageUtil.Side.LEFT -> ImageUtil.Side.RIGHT + } + } + + if (page !is InsertPage) { + onPageSplit() + } + + return ImageUtil.splitInHalf(inputStream, side) + } + + private fun onPageSplit() { + val newPage = InsertPage(page) + viewer.onPageSplit(page, newPage) + } + + /** + * Called when the page has an error. + */ + private fun setError() { + progressBar.isVisible = false + initRetryButton().isVisible = true + } + + /** + * Called when the image is decoded and going to be displayed. + */ + private fun onImageDecoded() { + progressBar.isVisible = false + } + + /** + * Called when an image fails to decode. + */ + private fun onImageDecodeError() { + progressBar.isVisible = false + initDecodeErrorLayout().isVisible = true + } + + /** + * Creates a new progress bar. + */ + @SuppressLint("PrivateResource") + private fun createProgressBar(): WatcherProgressBar { + return WatcherProgressBar(context, null).apply { + val size = 48.dpToPx + layoutParams = LayoutParams(size, size).apply { + gravity = Gravity.CENTER + } + } + } + + /** + * Initializes a subsampling scale view. + */ + private fun initSubsamplingImageView(): SubsamplingScaleImageView { + if (subsamplingImageView != null) return subsamplingImageView!! + + val config = viewer.config + + subsamplingImageView = SubsamplingScaleImageView(context).apply { + layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) + setMaxTileSize(viewer.activity.maxBitmapSize) + setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER) + setDoubleTapZoomDuration(config.doubleTapAnimDuration) + setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE) + setMinimumScaleType(config.imageScaleType) + setMinimumDpi(90) + setMinimumTileDpi(180) + setCropBorders(config.imageCropBorders) + setOnImageEventListener( + object : SubsamplingScaleImageView.DefaultOnImageEventListener() { + override fun onReady() { + when (config.imageZoomType) { + ZoomType.Left -> setScaleAndCenter(scale, PointF(0f, 0f)) + ZoomType.Right -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0f)) + ZoomType.Center -> setScaleAndCenter(scale, center.also { it?.y = 0f }) + } + onImageDecoded() + } + + override fun onImageLoadError(e: Exception) { + onImageDecodeError() + } + } + ) + } + addView(subsamplingImageView) + return subsamplingImageView!! + } + + /** + * Initializes an image view, used for GIFs. + */ + private fun initImageView(): ImageView { + if (imageView != null) return imageView!! + + imageView = PhotoView(context, null).apply { + layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) + adjustViewBounds = true + setZoomTransitionDuration(viewer.config.doubleTapAnimDuration) + setScaleLevels(1f, 2f, 3f) + // Force 2 scale levels on double tap + setOnDoubleTapListener( + object : GestureDetector.SimpleOnGestureListener() { + override fun onDoubleTap(e: MotionEvent): Boolean { + if (scale > 1f) { + setScale(1f, e.x, e.y, true) + } else { + setScale(2f, e.x, e.y, true) + } + return true + } + } + ) + } + addView(imageView) + return imageView!! + } + + /** + * Initializes a button to retry pages. + */ + private fun initRetryButton(): PagerButton { + if (retryButton != null) return retryButton!! + + retryButton = PagerButton(context, viewer).apply { + layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + gravity = Gravity.CENTER + } + setText(R.string.action_retry) + setOnClickListener { + page.episode.pageLoader?.retryPage(page) + } + } + addView(retryButton) + return retryButton!! + } + + /** + * Initializes a decode error layout. + */ + private fun initDecodeErrorLayout(): ViewGroup { + if (decodeErrorLayout != null) return decodeErrorLayout!! + + val margins = 8.dpToPx + + val decodeLayout = LinearLayout(context).apply { + gravity = Gravity.CENTER + orientation = LinearLayout.VERTICAL + } + decodeErrorLayout = decodeLayout + + TextView(context).apply { + layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + setMargins(margins, margins, margins, margins) + } + gravity = Gravity.CENTER + setText(R.string.decode_image_error) + + decodeLayout.addView(this) + } + + PagerButton(context, viewer).apply { + layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + setMargins(margins, margins, margins, margins) + } + setText(R.string.action_retry) + setOnClickListener { + page.episode.pageLoader?.retryPage(page) + } + + decodeLayout.addView(this) + } + + val imageUrl = page.imageUrl + if (imageUrl.orEmpty().startsWith("http", true)) { + PagerButton(context, viewer).apply { + layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + setMargins(margins, margins, margins, margins) + } + setText(R.string.action_open_in_web_view) + setOnClickListener { + val intent = WebViewActivity.newIntent(context, imageUrl!!) + context.startActivity(intent) + } + + decodeLayout.addView(this) + } + } + + addView(decodeLayout) + return decodeLayout + } + + /** + * Extension method to set a [stream] into this ImageView. + */ + private fun ImageView.setImage(stream: InputStream) { + GlideApp.with(this) + .load(stream) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .transition(DrawableTransitionOptions.with(NoTransition.getFactory())) + .listener( + object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + onImageDecodeError() + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + if (resource is GifDrawable) { + resource.setLoopCount(GifDrawable.LOOP_INTRINSIC) + } + onImageDecoded() + return false + } + } + ) + .into(this) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerTransitionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerTransitionHolder.kt new file mode 100644 index 000000000..259228454 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerTransitionHolder.kt @@ -0,0 +1,147 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.pager + +import android.annotation.SuppressLint +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.LinearLayout +import android.widget.ProgressBar +import androidx.appcompat.widget.AppCompatTextView +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.watcher.model.EpisodeTransition +import eu.kanade.tachiyomi.ui.watcher.model.WatcherEpisode +import eu.kanade.tachiyomi.ui.watcher.viewer.WatcherTransitionView +import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.widget.ViewPagerAdapter +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers + +/** + * View of the ViewPager that contains a episode transition. + */ +@SuppressLint("ViewConstructor") +class PagerTransitionHolder( + val viewer: PagerViewer, + val transition: EpisodeTransition +) : LinearLayout(viewer.activity), ViewPagerAdapter.PositionableView { + + /** + * Item that identifies this view. Needed by the adapter to not recreate views. + */ + override val item: Any + get() = transition + + /** + * Subscription for status changes of the transition page. + */ + private var statusSubscription: Subscription? = null + + /** + * View container of the current status of the transition page. Child views will be added + * dynamically. + */ + private var pagesContainer = LinearLayout(context).apply { + layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) + orientation = VERTICAL + gravity = Gravity.CENTER + } + + init { + orientation = VERTICAL + gravity = Gravity.CENTER + val sidePadding = 64.dpToPx + setPadding(sidePadding, 0, sidePadding, 0) + + val transitionView = WatcherTransitionView(context) + addView(transitionView) + addView(pagesContainer) + + transitionView.bind(transition) + + transition.to?.let { observeStatus(it) } + } + + /** + * Called when this view is detached from the window. Unsubscribes any active subscription. + */ + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + statusSubscription?.unsubscribe() + statusSubscription = null + } + + /** + * Observes the status of the page list of the next/previous episode. Whenever there's a new + * state, the pages container is cleaned up before setting the new state. + */ + private fun observeStatus(episode: WatcherEpisode) { + statusSubscription?.unsubscribe() + statusSubscription = episode.stateObserver + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { state -> + pagesContainer.removeAllViews() + when (state) { + is WatcherEpisode.State.Wait -> { + } + is WatcherEpisode.State.Loading -> setLoading() + is WatcherEpisode.State.Error -> setError(state.error) + is WatcherEpisode.State.Loaded -> setLoaded() + } + } + } + + /** + * Sets the loading state on the pages container. + */ + private fun setLoading() { + val progress = ProgressBar(context, null, android.R.attr.progressBarStyle) + + val textView = AppCompatTextView(context).apply { + wrapContent() + setText(R.string.transition_pages_loading) + } + + pagesContainer.addView(progress) + pagesContainer.addView(textView) + } + + /** + * Sets the loaded state on the pages container. + */ + private fun setLoaded() { + // No additional view is added + } + + /** + * Sets the error state on the pages container. + */ + private fun setError(error: Throwable) { + val textView = AppCompatTextView(context).apply { + wrapContent() + text = context.getString(R.string.transition_pages_error, error.message) + } + + val retryBtn = PagerButton(context, viewer).apply { + wrapContent() + setText(R.string.action_retry) + setOnClickListener { + val toEpisode = transition.to + if (toEpisode != null) { + viewer.activity.requestPreloadEpisode(toEpisode) + } + } + } + + pagesContainer.addView(textView) + pagesContainer.addView(retryBtn) + } + + /** + * Extension method to set layout params to wrap content on this view. + */ + private fun View.wrapContent() { + layoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerViewer.kt new file mode 100644 index 000000000..38a0072ec --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerViewer.kt @@ -0,0 +1,394 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.pager + +import android.graphics.PointF +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup.LayoutParams +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.viewpager.widget.ViewPager +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.watcher.WatcherActivity +import eu.kanade.tachiyomi.ui.watcher.model.EpisodeTransition +import eu.kanade.tachiyomi.ui.watcher.model.InsertPage +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import eu.kanade.tachiyomi.ui.watcher.model.ViewerEpisodes +import eu.kanade.tachiyomi.ui.watcher.viewer.BaseViewer +import eu.kanade.tachiyomi.ui.watcher.viewer.ViewerNavigation.NavigationRegion +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import timber.log.Timber +import kotlin.math.min + +/** + * Implementation of a [BaseViewer] to display pages with a [ViewPager]. + */ +@Suppress("LeakingThis") +abstract class PagerViewer(val activity: WatcherActivity) : BaseViewer { + + private val scope = MainScope() + + /** + * View pager used by this viewer. It's abstract to implement L2R, R2L and vertical pagers on + * top of this class. + */ + val pager = createPager() + + /** + * Configuration used by the pager, like allow taps, scale mode on images, page transitions... + */ + val config = PagerConfig(this, scope) + + /** + * Adapter of the pager. + */ + private val adapter = PagerViewerAdapter(this) + + /** + * Currently active item. It can be a episode page or a episode transition. + */ + private var currentPage: Any? = null + + /** + * Viewer episodes to set when the pager enters idle mode. Otherwise, if the view was settling + * or dragging, there'd be a noticeable and annoying jump. + */ + private var awaitingIdleViewerEpisodes: ViewerEpisodes? = null + + /** + * Whether the view pager is currently in idle mode. It sets the awaiting episodes if setting + * this field to true. + */ + private var isIdle = true + set(value) { + field = value + if (value) { + awaitingIdleViewerEpisodes?.let { + setEpisodesInternal(it) + awaitingIdleViewerEpisodes = null + } + } + } + + init { + pager.isVisible = false // Don't layout the pager yet + pager.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + pager.offscreenPageLimit = 1 + pager.id = R.id.watcher_pager + pager.adapter = adapter + pager.addOnPageChangeListener( + object : ViewPager.SimpleOnPageChangeListener() { + override fun onPageSelected(position: Int) { + onPageChange(position) + } + + override fun onPageScrollStateChanged(state: Int) { + isIdle = state == ViewPager.SCROLL_STATE_IDLE + } + } + ) + pager.tapListener = f@{ event -> + if (!config.tappingEnabled) { + activity.toggleMenu() + return@f + } + + val pos = PointF(event.rawX / pager.width, event.rawY / pager.height) + val navigator = config.navigator + when (navigator.getAction(pos)) { + NavigationRegion.MENU -> activity.toggleMenu() + NavigationRegion.NEXT -> moveToNext() + NavigationRegion.PREV -> moveToPrevious() + NavigationRegion.RIGHT -> moveRight() + NavigationRegion.LEFT -> moveLeft() + } + } + pager.longTapListener = f@{ + if (activity.menuVisible || config.longTapEnabled) { + val item = adapter.items.getOrNull(pager.currentItem) + if (item is WatcherPage) { + activity.onPageLongTap(item) + return@f true + } + } + false + } + + config.dualPageSplitChangedListener = { enabled -> + if (!enabled) { + cleanupPageSplit() + } + } + + config.imagePropertyChangedListener = { + refreshAdapter() + } + + config.navigationModeChangedListener = { + val showOnStart = config.navigationOverlayOnStart || config.forceNavigationOverlay + activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart) + } + } + + override fun destroy() { + super.destroy() + scope.cancel() + } + + /** + * Creates a new ViewPager. + */ + abstract fun createPager(): Pager + + /** + * Returns the view this viewer uses. + */ + override fun getView(): View { + return pager + } + + /** + * Called when a new page (either a [WatcherPage] or [EpisodeTransition]) is marked as active + */ + private fun onPageChange(position: Int) { + val page = adapter.items.getOrNull(position) + if (page != null && currentPage != page) { + val allowPreload = checkAllowPreload(page as? WatcherPage) + currentPage = page + when (page) { + is WatcherPage -> onWatcherPageSelected(page, allowPreload) + is EpisodeTransition -> onTransitionSelected(page) + } + } + } + + private fun checkAllowPreload(page: WatcherPage?): Boolean { + // Page is transition page - preload allowed + page ?: return true + + // Initial opening - preload allowed + currentPage ?: return true + + // Allow preload for + // 1. Going to next episode from episode transition + // 2. Going between pages of same episode + // 3. Next episode page + return when (page.episode) { + (currentPage as? EpisodeTransition.Next)?.to -> true + (currentPage as? WatcherPage)?.episode -> true + adapter.nextTransition?.to -> true + else -> false + } + } + + /** + * Called when a [WatcherPage] is marked as active. It notifies the + * activity of the change and requests the preload of the next episode if this is the last page. + */ + private fun onWatcherPageSelected(page: WatcherPage, allowPreload: Boolean) { + val pages = page.episode.pages ?: return + Timber.d("onWatcherPageSelected: ${page.number}/${pages.size}") + activity.onPageSelected(page) + + // Preload next episode once we're within the last 5 pages of the current episode + val inPreloadRange = pages.size - page.number < 5 + if (inPreloadRange && allowPreload && page.episode == adapter.currentEpisode) { + Timber.d("Request preload next episode because we're at page ${page.number} of ${pages.size}") + adapter.nextTransition?.to?.let { + activity.requestPreloadEpisode(it) + } + } + } + + /** + * Called when a [EpisodeTransition] is marked as active. It request the + * preload of the destination episode of the transition. + */ + private fun onTransitionSelected(transition: EpisodeTransition) { + Timber.d("onTransitionSelected: $transition") + val toEpisode = transition.to + if (toEpisode != null) { + Timber.d("Request preload destination episode because we're on the transition") + activity.requestPreloadEpisode(toEpisode) + } else if (transition is EpisodeTransition.Next) { + // No more episodes, show menu because the user is probably going to close the watcher + activity.showMenu() + } + } + + /** + * Tells this viewer to set the given [episodes] as active. If the pager is currently idle, + * it sets the episodes immediately, otherwise they are saved and set when it becomes idle. + */ + override fun setEpisodes(episodes: ViewerEpisodes) { + if (isIdle) { + setEpisodesInternal(episodes) + } else { + awaitingIdleViewerEpisodes = episodes + } + } + + /** + * Sets the active [episodes] on this pager. + */ + private fun setEpisodesInternal(episodes: ViewerEpisodes) { + Timber.d("setEpisodesInternal") + val forceTransition = config.alwaysShowEpisodeTransition || adapter.items.getOrNull(pager.currentItem) is EpisodeTransition + adapter.setEpisodes(episodes, forceTransition) + + // Layout the pager once a episode is being set + if (pager.isGone) { + Timber.d("Pager first layout") + val pages = episodes.currEpisode.pages ?: return + moveToPage(pages[min(episodes.currEpisode.requestedPage, pages.lastIndex)]) + pager.isVisible = true + } + } + + /** + * Tells this viewer to move to the given [page]. + */ + override fun moveToPage(page: WatcherPage) { + Timber.d("moveToPage ${page.number}") + val position = adapter.items.indexOf(page) + if (position != -1) { + val currentPosition = pager.currentItem + pager.setCurrentItem(position, true) + // manually call onPageChange since ViewPager listener is not triggered in this case + if (currentPosition == position) { + onPageChange(position) + } + } else { + Timber.d("Page $page not found in adapter") + } + } + + /** + * Moves to the next page. + */ + open fun moveToNext() { + moveRight() + } + + /** + * Moves to the previous page. + */ + open fun moveToPrevious() { + moveLeft() + } + + /** + * Moves to the page at the right. + */ + protected open fun moveRight() { + if (pager.currentItem != adapter.count - 1) { + pager.setCurrentItem(pager.currentItem + 1, config.usePageTransitions) + } + } + + /** + * Moves to the page at the left. + */ + protected open fun moveLeft() { + if (pager.currentItem != 0) { + pager.setCurrentItem(pager.currentItem - 1, config.usePageTransitions) + } + } + + /** + * Moves to the page at the top (or previous). + */ + protected open fun moveUp() { + moveToPrevious() + } + + /** + * Moves to the page at the bottom (or next). + */ + protected open fun moveDown() { + moveToNext() + } + + /** + * Resets the adapter in order to recreate all the views. Used when a image configuration is + * changed. + */ + private fun refreshAdapter() { + val currentItem = pager.currentItem + pager.adapter = adapter + pager.setCurrentItem(currentItem, false) + } + + /** + * Called from the containing activity when a key [event] is received. It should return true + * if the event was handled, false otherwise. + */ + override fun handleKeyEvent(event: KeyEvent): Boolean { + val isUp = event.action == KeyEvent.ACTION_UP + val ctrlPressed = event.metaState.and(KeyEvent.META_CTRL_ON) > 0 + + when (event.keyCode) { + KeyEvent.KEYCODE_VOLUME_DOWN -> { + if (!config.volumeKeysEnabled || activity.menuVisible) { + return false + } else if (isUp) { + if (!config.volumeKeysInverted) moveDown() else moveUp() + } + } + KeyEvent.KEYCODE_VOLUME_UP -> { + if (!config.volumeKeysEnabled || activity.menuVisible) { + return false + } else if (isUp) { + if (!config.volumeKeysInverted) moveUp() else moveDown() + } + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (isUp) { + if (ctrlPressed) moveToNext() else moveRight() + } + } + KeyEvent.KEYCODE_DPAD_LEFT -> { + if (isUp) { + if (ctrlPressed) moveToPrevious() else moveLeft() + } + } + KeyEvent.KEYCODE_DPAD_DOWN -> if (isUp) moveDown() + KeyEvent.KEYCODE_DPAD_UP -> if (isUp) moveUp() + KeyEvent.KEYCODE_PAGE_DOWN -> if (isUp) moveDown() + KeyEvent.KEYCODE_PAGE_UP -> if (isUp) moveUp() + KeyEvent.KEYCODE_MENU -> if (isUp) activity.toggleMenu() + else -> return false + } + return true + } + + /** + * Called from the containing activity when a generic motion [event] is received. It should + * return true if the event was handled, false otherwise. + */ + override fun handleGenericMotionEvent(event: MotionEvent): Boolean { + if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) { + when (event.action) { + MotionEvent.ACTION_SCROLL -> { + if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0.0f) { + moveDown() + } else { + moveUp() + } + return true + } + } + } + return false + } + + fun onPageSplit(currentPage: WatcherPage, newPage: InsertPage) { + adapter.onPageSplit(currentPage, newPage, this::class.java) + } + + private fun cleanupPageSplit() { + adapter.cleanupPageSplit() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerViewerAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerViewerAdapter.kt new file mode 100644 index 000000000..18a9d39f3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerViewerAdapter.kt @@ -0,0 +1,160 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.pager + +import android.view.View +import android.view.ViewGroup +import eu.kanade.tachiyomi.ui.watcher.model.EpisodeTransition +import eu.kanade.tachiyomi.ui.watcher.model.InsertPage +import eu.kanade.tachiyomi.ui.watcher.model.WatcherEpisode +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import eu.kanade.tachiyomi.ui.watcher.model.ViewerEpisodes +import eu.kanade.tachiyomi.ui.watcher.viewer.hasMissingEpisodes +import eu.kanade.tachiyomi.widget.ViewPagerAdapter +import timber.log.Timber + +/** + * Pager adapter used by this [viewer] to where [ViewerEpisodes] updates are posted. + */ +class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() { + + /** + * List of currently set items. + */ + var items: MutableList = mutableListOf() + private set + + var nextTransition: EpisodeTransition.Next? = null + private set + + var currentEpisode: WatcherEpisode? = null + + /** + * Updates this adapter with the given [episodes]. It handles setting a few pages of the + * next/previous episode to allow seamless transitions and inverting the pages if the viewer + * has R2L direction. + */ + fun setEpisodes(episodes: ViewerEpisodes, forceTransition: Boolean) { + val newItems = mutableListOf() + + // Forces episode transition if there is missing episodes + val prevHasMissingEpisodes = hasMissingEpisodes(episodes.currEpisode, episodes.prevEpisode) + val nextHasMissingEpisodes = hasMissingEpisodes(episodes.nextEpisode, episodes.currEpisode) + + // Add previous episode pages and transition. + if (episodes.prevEpisode != null) { + // We only need to add the last few pages of the previous episode, because it'll be + // selected as the current episode when one of those pages is selected. + val prevPages = episodes.prevEpisode.pages + if (prevPages != null) { + newItems.addAll(prevPages.takeLast(2)) + } + } + + // Skip transition page if the episode is loaded & current page is not a transition page + if (prevHasMissingEpisodes || forceTransition || episodes.prevEpisode?.state !is WatcherEpisode.State.Loaded) { + newItems.add(EpisodeTransition.Prev(episodes.currEpisode, episodes.prevEpisode)) + } + + // Add current episode. + val currPages = episodes.currEpisode.pages + if (currPages != null) { + newItems.addAll(currPages) + } + + currentEpisode = episodes.currEpisode + + // Add next episode transition and pages. + nextTransition = EpisodeTransition.Next(episodes.currEpisode, episodes.nextEpisode) + .also { + if (nextHasMissingEpisodes || forceTransition || + episodes.nextEpisode?.state !is WatcherEpisode.State.Loaded + ) { + newItems.add(it) + } + } + + if (episodes.nextEpisode != null) { + // Add at most two pages, because this episode will be selected before the user can + // swap more pages. + val nextPages = episodes.nextEpisode.pages + if (nextPages != null) { + newItems.addAll(nextPages.take(2)) + } + } + + // Resets double-page splits, else insert pages get misplaced + items.filterIsInstance().also { items.removeAll(it) } + + if (viewer is R2LPagerViewer) { + newItems.reverse() + } + + items = newItems + notifyDataSetChanged() + } + + /** + * Returns the amount of items of the adapter. + */ + override fun getCount(): Int { + return items.size + } + + /** + * Creates a new view for the item at the given [position]. + */ + override fun createView(container: ViewGroup, position: Int): View { + return when (val item = items[position]) { + is WatcherPage -> PagerPageHolder(viewer, item) + is EpisodeTransition -> PagerTransitionHolder(viewer, item) + else -> throw NotImplementedError("Holder for ${item.javaClass} not implemented") + } + } + + /** + * Returns the current position of the given [view] on the adapter. + */ + override fun getItemPosition(view: Any): Int { + if (view is PositionableView) { + val position = items.indexOf(view.item) + if (position != -1) { + return position + } else { + Timber.d("Position for ${view.item} not found") + } + } + return POSITION_NONE + } + + fun onPageSplit(current: Any?, newPage: InsertPage, clazz: Class) { + if (current !is WatcherPage) return + + val currentIndex = items.indexOf(current) + + val placeAtIndex = when { + clazz.isAssignableFrom(L2RPagerViewer::class.java) -> currentIndex + 1 + clazz.isAssignableFrom(VerticalPagerViewer::class.java) -> currentIndex + 1 + clazz.isAssignableFrom(R2LPagerViewer::class.java) -> currentIndex + else -> currentIndex + } + + // It will enter a endless cycle of insert pages + if (clazz.isAssignableFrom(R2LPagerViewer::class.java) && items[placeAtIndex - 1] is InsertPage) { + return + } + + // Same here it will enter a endless cycle of insert pages + if (items[placeAtIndex] is InsertPage) { + return + } + + items.add(placeAtIndex, newPage) + + notifyDataSetChanged() + } + + fun cleanupPageSplit() { + val insertPages = items.filterIsInstance(InsertPage::class.java) + items.removeAll(insertPages) + notifyDataSetChanged() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerViewers.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerViewers.kt new file mode 100644 index 000000000..e734d508b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/pager/PagerViewers.kt @@ -0,0 +1,53 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.pager + +import eu.kanade.tachiyomi.ui.watcher.WatcherActivity + +/** + * Implementation of a left to right PagerViewer. + */ +class L2RPagerViewer(activity: WatcherActivity) : PagerViewer(activity) { + /** + * Creates a new left to right pager. + */ + override fun createPager(): Pager { + return Pager(activity) + } +} + +/** + * Implementation of a right to left PagerViewer. + */ +class R2LPagerViewer(activity: WatcherActivity) : PagerViewer(activity) { + /** + * Creates a new right to left pager. + */ + override fun createPager(): Pager { + return Pager(activity) + } + + /** + * Moves to the next page. On a R2L pager the next page is the one at the left. + */ + override fun moveToNext() { + moveLeft() + } + + /** + * Moves to the previous page. On a R2L pager the previous page is the one at the right. + */ + override fun moveToPrevious() { + moveRight() + } +} + +/** + * Implementation of a vertical (top to bottom) PagerViewer. + */ +class VerticalPagerViewer(activity: WatcherActivity) : PagerViewer(activity) { + /** + * Creates a new vertical pager. + */ + override fun createPager(): Pager { + return Pager(activity, isHorizontal = false) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonAdapter.kt new file mode 100644 index 000000000..52f096fd2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonAdapter.kt @@ -0,0 +1,187 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.webtoon + +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import eu.kanade.tachiyomi.ui.watcher.model.EpisodeTransition +import eu.kanade.tachiyomi.ui.watcher.model.WatcherEpisode +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import eu.kanade.tachiyomi.ui.watcher.model.ViewerEpisodes +import eu.kanade.tachiyomi.ui.watcher.viewer.hasMissingEpisodes + +/** + * RecyclerView Adapter used by this [viewer] to where [ViewerEpisodes] updates are posted. + */ +class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter() { + + /** + * List of currently set items. + */ + var items: List = emptyList() + private set + + var currentEpisode: WatcherEpisode? = null + + /** + * Updates this adapter with the given [episodes]. It handles setting a few pages of the + * next/previous episode to allow seamless transitions. + */ + fun setEpisodes(episodes: ViewerEpisodes, forceTransition: Boolean) { + val newItems = mutableListOf() + + // Forces episode transition if there is missing episodes + val prevHasMissingEpisodes = hasMissingEpisodes(episodes.currEpisode, episodes.prevEpisode) + val nextHasMissingEpisodes = hasMissingEpisodes(episodes.nextEpisode, episodes.currEpisode) + + // Add previous episode pages and transition. + if (episodes.prevEpisode != null) { + // We only need to add the last few pages of the previous episode, because it'll be + // selected as the current episode when one of those pages is selected. + val prevPages = episodes.prevEpisode.pages + if (prevPages != null) { + newItems.addAll(prevPages.takeLast(2)) + } + } + + // Skip transition page if the episode is loaded & current page is not a transition page + if (prevHasMissingEpisodes || forceTransition || episodes.prevEpisode?.state !is WatcherEpisode.State.Loaded) { + newItems.add(EpisodeTransition.Prev(episodes.currEpisode, episodes.prevEpisode)) + } + + // Add current episode. + val currPages = episodes.currEpisode.pages + if (currPages != null) { + newItems.addAll(currPages) + } + + currentEpisode = episodes.currEpisode + + // Add next episode transition and pages. + if (nextHasMissingEpisodes || forceTransition || episodes.nextEpisode?.state !is WatcherEpisode.State.Loaded) { + newItems.add(EpisodeTransition.Next(episodes.currEpisode, episodes.nextEpisode)) + } + + if (episodes.nextEpisode != null) { + // Add at most two pages, because this episode will be selected before the user can + // swap more pages. + val nextPages = episodes.nextEpisode.pages + if (nextPages != null) { + newItems.addAll(nextPages.take(2)) + } + } + + val result = DiffUtil.calculateDiff(Callback(items, newItems)) + items = newItems + result.dispatchUpdatesTo(this) + } + + /** + * Returns the amount of items of the adapter. + */ + override fun getItemCount(): Int { + return items.size + } + + /** + * Returns the view type for the item at the given [position]. + */ + override fun getItemViewType(position: Int): Int { + return when (val item = items[position]) { + is WatcherPage -> PAGE_VIEW + is EpisodeTransition -> TRANSITION_VIEW + else -> error("Unknown view type for ${item.javaClass}") + } + } + + /** + * Creates a new view holder for an item with the given [viewType]. + */ + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + PAGE_VIEW -> { + val view = FrameLayout(parent.context) + WebtoonPageHolder(view, viewer) + } + TRANSITION_VIEW -> { + val view = LinearLayout(parent.context) + WebtoonTransitionHolder(view, viewer) + } + else -> error("Unknown view type") + } + } + + /** + * Binds an existing view [holder] with the item at the given [position]. + */ + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = items[position] + when (holder) { + is WebtoonPageHolder -> holder.bind(item as WatcherPage) + is WebtoonTransitionHolder -> holder.bind(item as EpisodeTransition) + } + } + + /** + * Recycles an existing view [holder] before adding it to the view pool. + */ + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + when (holder) { + is WebtoonPageHolder -> holder.recycle() + is WebtoonTransitionHolder -> holder.recycle() + } + } + + /** + * Diff util callback used to dispatch delta updates instead of full dataset changes. + */ + private class Callback( + private val oldItems: List, + private val newItems: List + ) : DiffUtil.Callback() { + + /** + * Returns true if these two items are the same. + */ + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldItems[oldItemPosition] + val newItem = newItems[newItemPosition] + + return oldItem == newItem + } + + /** + * Returns true if the contents of the items are the same. + */ + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return true + } + + /** + * Returns the size of the old list. + */ + override fun getOldListSize(): Int { + return oldItems.size + } + + /** + * Returns the size of the new list. + */ + override fun getNewListSize(): Int { + return newItems.size + } + } + + private companion object { + /** + * View holder type of a episode page view. + */ + const val PAGE_VIEW = 0 + + /** + * View holder type of a episode transition view. + */ + const val TRANSITION_VIEW = 1 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonBaseHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonBaseHolder.kt new file mode 100644 index 000000000..9c8189676 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonBaseHolder.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.webtoon + +import android.content.Context +import android.view.View +import android.view.ViewGroup.LayoutParams +import androidx.recyclerview.widget.RecyclerView +import rx.Subscription + +abstract class WebtoonBaseHolder( + view: View, + protected val viewer: WebtoonViewer +) : RecyclerView.ViewHolder(view) { + + /** + * Context getter because it's used often. + */ + val context: Context get() = itemView.context + + /** + * Called when the view is recycled and being added to the view pool. + */ + open fun recycle() {} + + /** + * Adds a subscription to a list of subscriptions that will automatically unsubscribe when the + * activity or the watcher is destroyed. + */ + protected fun addSubscription(subscription: Subscription?) { + viewer.subscriptions.add(subscription) + } + + /** + * Removes a subscription from the list of subscriptions. + */ + protected fun removeSubscription(subscription: Subscription?) { + subscription?.let { viewer.subscriptions.remove(it) } + } + + /** + * Extension method to set layout params to wrap content on this view. + */ + protected fun View.wrapContent() { + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonConfig.kt new file mode 100644 index 000000000..a07fd9f39 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonConfig.kt @@ -0,0 +1,75 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.webtoon + +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.watcher.viewer.ViewerConfig +import eu.kanade.tachiyomi.ui.watcher.viewer.ViewerNavigation +import eu.kanade.tachiyomi.ui.watcher.viewer.navigation.EdgeNavigation +import eu.kanade.tachiyomi.ui.watcher.viewer.navigation.KindlishNavigation +import eu.kanade.tachiyomi.ui.watcher.viewer.navigation.LNavigation +import eu.kanade.tachiyomi.ui.watcher.viewer.navigation.RightAndLeftNavigation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * Configuration used by webtoon viewers. + */ +class WebtoonConfig( + scope: CoroutineScope, + preferences: PreferencesHelper = Injekt.get() +) : ViewerConfig(preferences, scope) { + + var imageCropBorders = false + private set + + var sidePadding = 0 + private set + + init { + preferences.cropBordersWebtoon() + .register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() }) + + preferences.webtoonSidePadding() + .register({ sidePadding = it }, { imagePropertyChangedListener?.invoke() }) + + preferences.navigationModeWebtoon() + .register({ navigationMode = it }, { updateNavigation(it) }) + + preferences.webtoonNavInverted() + .register({ tappingInverted = it }, { navigator.invertMode = it }) + preferences.webtoonNavInverted().asFlow() + .drop(1) + .onEach { navigationModeChangedListener?.invoke() } + .launchIn(scope) + + preferences.dualPageSplitWebtoon() + .register({ dualPageSplit = it }, { imagePropertyChangedListener?.invoke() }) + + preferences.dualPageInvertWebtoon() + .register({ dualPageInvert = it }, { imagePropertyChangedListener?.invoke() }) + } + + override var navigator: ViewerNavigation = defaultNavigation() + set(value) { + field = value.also { it.invertMode = tappingInverted } + } + + override fun defaultNavigation(): ViewerNavigation { + return LNavigation() + } + + override fun updateNavigation(navigationMode: Int) { + this.navigator = when (navigationMode) { + 0 -> defaultNavigation() + 1 -> LNavigation() + 2 -> KindlishNavigation() + 3 -> EdgeNavigation() + 4 -> RightAndLeftNavigation() + else -> defaultNavigation() + } + navigationModeChangedListener?.invoke() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonFrame.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonFrame.kt new file mode 100644 index 000000000..d74a0337f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonFrame.kt @@ -0,0 +1,79 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.webtoon + +import android.content.Context +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.widget.FrameLayout + +/** + * Frame layout which contains a [WebtoonRecyclerView]. It's needed to handle touch events, + * because the recyclerview is scaled and its touch events are translated, which breaks the + * detectors. + * + * TODO consider integrating this class into [WebtoonViewer]. + */ +class WebtoonFrame(context: Context) : FrameLayout(context) { + + /** + * Scale detector, either with pinch or quick scale. + */ + private val scaleDetector = ScaleGestureDetector(context, ScaleListener()) + + /** + * Fling detector. + */ + private val flingDetector = GestureDetector(context, FlingListener()) + + /** + * Recycler view added in this frame. + */ + private val recycler: WebtoonRecyclerView? + get() = getChildAt(0) as? WebtoonRecyclerView + + /** + * Dispatches a touch event to the detectors. + */ + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + scaleDetector.onTouchEvent(ev) + flingDetector.onTouchEvent(ev) + return super.dispatchTouchEvent(ev) + } + + /** + * Scale listener used to delegate events to the recycler view. + */ + inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScaleBegin(detector: ScaleGestureDetector?): Boolean { + recycler?.onScaleBegin() + return true + } + + override fun onScale(detector: ScaleGestureDetector): Boolean { + recycler?.onScale(detector.scaleFactor) + return true + } + + override fun onScaleEnd(detector: ScaleGestureDetector) { + recycler?.onScaleEnd() + } + } + + /** + * Fling listener used to delegate events to the recycler view. + */ + inner class FlingListener : GestureDetector.SimpleOnGestureListener() { + override fun onDown(e: MotionEvent?): Boolean { + return true + } + + override fun onFling( + e1: MotionEvent?, + e2: MotionEvent?, + velocityX: Float, + velocityY: Float + ): Boolean { + return recycler?.zoomFling(velocityX.toInt(), velocityY.toInt()) ?: false + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonLayoutManager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonLayoutManager.kt new file mode 100644 index 000000000..31468da97 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonLayoutManager.kt @@ -0,0 +1,55 @@ +@file:Suppress("PackageDirectoryMismatch") + +package androidx.recyclerview.widget + +import androidx.recyclerview.widget.RecyclerView.NO_POSITION +import eu.kanade.tachiyomi.ui.watcher.WatcherActivity + +/** + * Layout manager used by the webtoon viewer. Item prefetch is disabled because the extra layout + * space feature is used which allows setting the image even if the holder is not visible, + * avoiding (in most cases) black views when they are visible. + * + * This layout manager uses the same package name as the support library in order to use a package + * protected method. + */ +class WebtoonLayoutManager(activity: WatcherActivity) : LinearLayoutManager(activity) { + + /** + * Extra layout space is set to half the screen height. + */ + private val extraLayoutSpace = activity.resources.displayMetrics.heightPixels / 2 + + init { + isItemPrefetchEnabled = false + } + + /** + * Returns the custom extra layout space. + */ + override fun getExtraLayoutSpace(state: RecyclerView.State): Int { + return extraLayoutSpace + } + + /** + * Returns the position of the last item whose end side is visible on screen. + */ + fun findLastEndVisibleItemPosition(): Int { + ensureLayoutState() + @ViewBoundsCheck.ViewBounds val preferredBoundsFlag = + (ViewBoundsCheck.FLAG_CVE_LT_PVE or ViewBoundsCheck.FLAG_CVE_EQ_PVE) + + val fromIndex = childCount - 1 + val toIndex = -1 + + val child = if (mOrientation == HORIZONTAL) { + mHorizontalBoundCheck + .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0) + } else { + mVerticalBoundCheck + .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0) + } + + return if (child == null) NO_POSITION else getPosition(child) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonPageHolder.kt new file mode 100644 index 000000000..011780e7e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonPageHolder.kt @@ -0,0 +1,544 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.webtoon + +import android.annotation.SuppressLint +import android.content.res.Resources +import android.graphics.drawable.Drawable +import android.view.Gravity +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.widget.AppCompatButton +import androidx.appcompat.widget.AppCompatImageView +import androidx.core.view.isVisible +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.bumptech.glide.load.resource.gif.GifDrawable +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.bumptech.glide.request.transition.NoTransition +import com.davemorrissey.labs.subscaleview.ImageSource +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import eu.kanade.tachiyomi.ui.watcher.viewer.WatcherProgressBar +import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import eu.kanade.tachiyomi.util.system.ImageUtil +import eu.kanade.tachiyomi.util.system.dpToPx +import rx.Observable +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import java.io.InputStream +import java.util.concurrent.TimeUnit + +/** + * Holder of the webtoon watcher for a single page of a episode. + * + * @param frame the root view for this holder. + * @param viewer the webtoon viewer. + * @constructor creates a new webtoon holder. + */ +class WebtoonPageHolder( + private val frame: FrameLayout, + viewer: WebtoonViewer +) : WebtoonBaseHolder(frame, viewer) { + + /** + * Loading progress bar to indicate the current progress. + */ + private val progressBar = createProgressBar() + + /** + * Progress bar container. Needed to keep a minimum height size of the holder, otherwise the + * adapter would create more views to fill the screen, which is not wanted. + */ + private lateinit var progressContainer: ViewGroup + + /** + * Image view that supports subsampling on zoom. + */ + private var subsamplingImageView: SubsamplingScaleImageView? = null + private var cropBorders: Boolean = false + + /** + * Simple image view only used on GIFs. + */ + private var imageView: ImageView? = null + + /** + * Retry button container used to allow retrying. + */ + private var retryContainer: ViewGroup? = null + + /** + * Error layout to show when the image fails to decode. + */ + private var decodeErrorLayout: ViewGroup? = null + + /** + * Getter to retrieve the height of the recycler view. + */ + private val parentHeight + get() = viewer.recycler.height + + /** + * Page of a episode. + */ + private var page: WatcherPage? = null + + /** + * Subscription for status changes of the page. + */ + private var statusSubscription: Subscription? = null + + /** + * Subscription for progress changes of the page. + */ + private var progressSubscription: Subscription? = null + + /** + * Subscription used to read the header of the image. This is needed in order to instantiate + * the appropiate image view depending if the image is animated (GIF). + */ + private var readImageHeaderSubscription: Subscription? = null + + init { + refreshLayoutParams() + } + + /** + * Binds the given [page] with this view holder, subscribing to its state. + */ + fun bind(page: WatcherPage) { + this.page = page + observeStatus() + refreshLayoutParams() + } + + private fun refreshLayoutParams() { + frame.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { + if (!viewer.isContinuous) { + bottomMargin = 15.dpToPx + } + + val margin = Resources.getSystem().displayMetrics.widthPixels * (viewer.config.sidePadding / 100f) + marginEnd = margin.toInt() + marginStart = margin.toInt() + } + } + + /** + * Called when the view is recycled and added to the view pool. + */ + override fun recycle() { + unsubscribeStatus() + unsubscribeProgress() + unsubscribeReadImageHeader() + + removeDecodeErrorLayout() + subsamplingImageView?.recycle() + subsamplingImageView?.isVisible = false + imageView?.let { GlideApp.with(frame).clear(it) } + imageView?.isVisible = false + progressBar.setProgress(0) + } + + /** + * Observes the status of the page and notify the changes. + * + * @see processStatus + */ + private fun observeStatus() { + unsubscribeStatus() + + val page = page ?: return + val loader = page.episode.pageLoader ?: return + statusSubscription = loader.getPage(page) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { processStatus(it) } + + addSubscription(statusSubscription) + } + + /** + * Observes the progress of the page and updates view. + */ + private fun observeProgress() { + unsubscribeProgress() + + val page = page ?: return + + progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS) + .map { page.progress } + .distinctUntilChanged() + .onBackpressureLatest() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { value -> progressBar.setProgress(value) } + + addSubscription(progressSubscription) + } + + /** + * Called when the status of the page changes. + * + * @param status the new status of the page. + */ + private fun processStatus(status: Int) { + when (status) { + Page.QUEUE -> setQueued() + Page.LOAD_PAGE -> setLoading() + Page.DOWNLOAD_IMAGE -> { + observeProgress() + setDownloading() + } + Page.READY -> { + setImage() + unsubscribeProgress() + } + Page.ERROR -> { + setError() + unsubscribeProgress() + } + } + } + + /** + * Unsubscribes from the status subscription. + */ + private fun unsubscribeStatus() { + removeSubscription(statusSubscription) + statusSubscription = null + } + + /** + * Unsubscribes from the progress subscription. + */ + private fun unsubscribeProgress() { + removeSubscription(progressSubscription) + progressSubscription = null + } + + /** + * Unsubscribes from the read image header subscription. + */ + private fun unsubscribeReadImageHeader() { + removeSubscription(readImageHeaderSubscription) + readImageHeaderSubscription = null + } + + /** + * Called when the page is queued. + */ + private fun setQueued() { + progressContainer.isVisible = true + progressBar.isVisible = true + retryContainer?.isVisible = false + removeDecodeErrorLayout() + } + + /** + * Called when the page is loading. + */ + private fun setLoading() { + progressContainer.isVisible = true + progressBar.isVisible = true + retryContainer?.isVisible = false + removeDecodeErrorLayout() + } + + /** + * Called when the page is downloading + */ + private fun setDownloading() { + progressContainer.isVisible = true + progressBar.isVisible = true + retryContainer?.isVisible = false + removeDecodeErrorLayout() + } + + /** + * Called when the page is ready. + */ + private fun setImage() { + progressContainer.isVisible = true + progressBar.isVisible = true + progressBar.completeAndFadeOut() + retryContainer?.isVisible = false + removeDecodeErrorLayout() + + unsubscribeReadImageHeader() + val streamFn = page?.stream ?: return + + var openStream: InputStream? = null + readImageHeaderSubscription = Observable + .fromCallable { + val stream = streamFn().buffered(16) + openStream = stream + + ImageUtil.findImageType(stream) == ImageUtil.ImageType.GIF + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { isAnimated -> + if (viewer.config.dualPageSplit) { + val (isDoublePage, stream) = ImageUtil.isDoublePage(openStream!!) + openStream = if (!isDoublePage) { + stream + } else { + val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT + ImageUtil.splitAndMerge(stream, upperSide) + } + } + if (!isAnimated) { + val subsamplingView = initSubsamplingImageView() + subsamplingView.isVisible = true + subsamplingView.setImage(ImageSource.inputStream(openStream!!)) + } else { + val imageView = initImageView() + imageView.isVisible = true + imageView.setImage(openStream!!) + } + } + // Keep the Rx stream alive to close the input stream only when unsubscribed + .flatMap { Observable.never() } + .doOnUnsubscribe { openStream?.close() } + .subscribe({}, {}) + + addSubscription(readImageHeaderSubscription) + } + + /** + * Called when the page has an error. + */ + private fun setError() { + progressContainer.isVisible = false + initRetryLayout().isVisible = true + } + + /** + * Called when the image is decoded and going to be displayed. + */ + private fun onImageDecoded() { + progressContainer.isVisible = false + } + + /** + * Called when the image fails to decode. + */ + private fun onImageDecodeError() { + progressContainer.isVisible = false + initDecodeErrorLayout().isVisible = true + } + + /** + * Creates a new progress bar. + */ + @SuppressLint("PrivateResource") + private fun createProgressBar(): WatcherProgressBar { + progressContainer = FrameLayout(context) + frame.addView(progressContainer, MATCH_PARENT, parentHeight) + + val progress = WatcherProgressBar(context).apply { + val size = 48.dpToPx + layoutParams = FrameLayout.LayoutParams(size, size).apply { + gravity = Gravity.CENTER_HORIZONTAL + setMargins(0, parentHeight / 4, 0, 0) + } + } + progressContainer.addView(progress) + return progress + } + + /** + * Initializes a subsampling scale view. + */ + private fun initSubsamplingImageView(): SubsamplingScaleImageView { + val config = viewer.config + + if (subsamplingImageView != null) { + if (config.imageCropBorders != cropBorders) { + cropBorders = config.imageCropBorders + subsamplingImageView!!.setCropBorders(config.imageCropBorders) + } + + return subsamplingImageView!! + } + + cropBorders = config.imageCropBorders + subsamplingImageView = WebtoonSubsamplingImageView(context).apply { + setMaxTileSize(viewer.activity.maxBitmapSize) + setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE) + setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_FIT_WIDTH) + setMinimumDpi(90) + setMinimumTileDpi(180) + setCropBorders(cropBorders) + setOnImageEventListener( + object : SubsamplingScaleImageView.DefaultOnImageEventListener() { + override fun onReady() { + onImageDecoded() + } + + override fun onImageLoadError(e: Exception) { + onImageDecodeError() + } + } + ) + } + frame.addView(subsamplingImageView, MATCH_PARENT, MATCH_PARENT) + return subsamplingImageView!! + } + + /** + * Initializes an image view, used for GIFs. + */ + private fun initImageView(): ImageView { + if (imageView != null) return imageView!! + + imageView = AppCompatImageView(context).apply { + adjustViewBounds = true + } + frame.addView(imageView, MATCH_PARENT, MATCH_PARENT) + return imageView!! + } + + /** + * Initializes a button to retry pages. + */ + private fun initRetryLayout(): ViewGroup { + if (retryContainer != null) return retryContainer!! + + retryContainer = FrameLayout(context) + frame.addView(retryContainer, MATCH_PARENT, parentHeight) + + AppCompatButton(context).apply { + layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + gravity = Gravity.CENTER_HORIZONTAL + setMargins(0, parentHeight / 4, 0, 0) + } + setText(R.string.action_retry) + setOnClickListener { + page?.let { it.episode.pageLoader?.retryPage(it) } + } + + retryContainer!!.addView(this) + } + return retryContainer!! + } + + /** + * Initializes a decode error layout. + */ + private fun initDecodeErrorLayout(): ViewGroup { + if (decodeErrorLayout != null) return decodeErrorLayout!! + + val margins = 8.dpToPx + + val decodeLayout = LinearLayout(context).apply { + layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, parentHeight).apply { + setMargins(0, parentHeight / 6, 0, 0) + } + gravity = Gravity.CENTER_HORIZONTAL + orientation = LinearLayout.VERTICAL + } + decodeErrorLayout = decodeLayout + + TextView(context).apply { + layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + setMargins(0, margins, 0, margins) + } + gravity = Gravity.CENTER + setText(R.string.decode_image_error) + + decodeLayout.addView(this) + } + + AppCompatButton(context).apply { + layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + setMargins(0, margins, 0, margins) + } + setText(R.string.action_retry) + setOnClickListener { + page?.let { it.episode.pageLoader?.retryPage(it) } + } + + decodeLayout.addView(this) + } + + val imageUrl = page?.imageUrl + if (imageUrl.orEmpty().startsWith("http", true)) { + AppCompatButton(context).apply { + layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + setMargins(0, margins, 0, margins) + } + setText(R.string.action_open_in_web_view) + setOnClickListener { + val intent = WebViewActivity.newIntent(context, imageUrl!!) + context.startActivity(intent) + } + + decodeLayout.addView(this) + } + } + + frame.addView(decodeLayout) + return decodeLayout + } + + /** + * Removes the decode error layout from the holder, if found. + */ + private fun removeDecodeErrorLayout() { + val layout = decodeErrorLayout + if (layout != null) { + frame.removeView(layout) + decodeErrorLayout = null + } + } + + /** + * Extension method to set a [stream] into this ImageView. + */ + private fun ImageView.setImage(stream: InputStream) { + GlideApp.with(this) + .load(stream) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .transition(DrawableTransitionOptions.with(NoTransition.getFactory())) + .listener( + object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + onImageDecodeError() + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + if (resource is GifDrawable) { + resource.setLoopCount(GifDrawable.LOOP_INTRINSIC) + } + onImageDecoded() + return false + } + } + ) + .into(this) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonRecyclerView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonRecyclerView.kt new file mode 100644 index 000000000..4302dc0ee --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonRecyclerView.kt @@ -0,0 +1,324 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.webtoon + +import android.animation.AnimatorSet +import android.animation.ValueAnimator +import android.content.Context +import android.util.AttributeSet +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.ViewConfiguration +import android.view.animation.DecelerateInterpolator +import androidx.core.animation.doOnEnd +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import eu.kanade.tachiyomi.ui.watcher.viewer.GestureDetectorWithLongTap +import kotlin.math.abs + +/** + * Implementation of a [RecyclerView] used by the webtoon watcher. + */ +open class WebtoonRecyclerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : RecyclerView(context, attrs, defStyle) { + + private var isZooming = false + private var atLastPosition = false + private var atFirstPosition = false + private var halfWidth = 0 + private var halfHeight = 0 + private var originalHeight = 0 + private var heightSet = false + private var firstVisibleItemPosition = 0 + private var lastVisibleItemPosition = 0 + private var currentScale = DEFAULT_RATE + + private val listener = GestureListener() + private val detector = Detector() + + var tapListener: ((MotionEvent) -> Unit)? = null + var longTapListener: ((MotionEvent) -> Boolean)? = null + + override fun onMeasure(widthSpec: Int, heightSpec: Int) { + halfWidth = MeasureSpec.getSize(widthSpec) / 2 + halfHeight = MeasureSpec.getSize(heightSpec) / 2 + if (!heightSet) { + originalHeight = MeasureSpec.getSize(heightSpec) + heightSet = true + } + super.onMeasure(widthSpec, heightSpec) + } + + override fun onTouchEvent(e: MotionEvent): Boolean { + detector.onTouchEvent(e) + return super.onTouchEvent(e) + } + + override fun onScrolled(dx: Int, dy: Int) { + super.onScrolled(dx, dy) + val layoutManager = layoutManager + lastVisibleItemPosition = + (layoutManager as LinearLayoutManager).findLastVisibleItemPosition() + firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() + } + + override fun onScrollStateChanged(state: Int) { + super.onScrollStateChanged(state) + val layoutManager = layoutManager + val visibleItemCount = layoutManager?.childCount ?: 0 + val totalItemCount = layoutManager?.itemCount ?: 0 + atLastPosition = visibleItemCount > 0 && lastVisibleItemPosition == totalItemCount - 1 + atFirstPosition = firstVisibleItemPosition == 0 + } + + private fun getPositionX(positionX: Float): Float { + if (currentScale < 1) { + return 0f + } + val maxPositionX = halfWidth * (currentScale - 1) + return positionX.coerceIn(-maxPositionX, maxPositionX) + } + + private fun getPositionY(positionY: Float): Float { + if (currentScale < 1) { + return (originalHeight / 2 - halfHeight).toFloat() + } + val maxPositionY = halfHeight * (currentScale - 1) + return positionY.coerceIn(-maxPositionY, maxPositionY) + } + + private fun zoom( + fromRate: Float, + toRate: Float, + fromX: Float, + toX: Float, + fromY: Float, + toY: Float + ) { + isZooming = true + val animatorSet = AnimatorSet() + val translationXAnimator = ValueAnimator.ofFloat(fromX, toX) + translationXAnimator.addUpdateListener { animation -> x = animation.animatedValue as Float } + + val translationYAnimator = ValueAnimator.ofFloat(fromY, toY) + translationYAnimator.addUpdateListener { animation -> y = animation.animatedValue as Float } + + val scaleAnimator = ValueAnimator.ofFloat(fromRate, toRate) + scaleAnimator.addUpdateListener { animation -> + setScaleRate(animation.animatedValue as Float) + } + animatorSet.playTogether(translationXAnimator, translationYAnimator, scaleAnimator) + animatorSet.duration = ANIMATOR_DURATION_TIME.toLong() + animatorSet.interpolator = DecelerateInterpolator() + animatorSet.start() + animatorSet.doOnEnd { + isZooming = false + currentScale = toRate + } + } + + fun zoomFling(velocityX: Int, velocityY: Int): Boolean { + if (currentScale <= 1f) return false + + val distanceTimeFactor = 0.4f + var newX: Float? = null + var newY: Float? = null + + if (velocityX != 0) { + val dx = (distanceTimeFactor * velocityX / 2) + newX = getPositionX(x + dx) + } + if (velocityY != 0 && (atFirstPosition || atLastPosition)) { + val dy = (distanceTimeFactor * velocityY / 2) + newY = getPositionY(y + dy) + } + + animate() + .apply { + newX?.let { x(it) } + newY?.let { y(it) } + } + .setInterpolator(DecelerateInterpolator()) + .setDuration(400) + .start() + + return true + } + + private fun zoomScrollBy(dx: Int, dy: Int) { + if (dx != 0) { + x = getPositionX(x + dx) + } + if (dy != 0) { + y = getPositionY(y + dy) + } + } + + private fun setScaleRate(rate: Float) { + scaleX = rate + scaleY = rate + } + + fun onScale(scaleFactor: Float) { + currentScale *= scaleFactor + currentScale = currentScale.coerceIn( + MIN_RATE, + MAX_SCALE_RATE + ) + + setScaleRate(currentScale) + + layoutParams.height = if (currentScale < 1) { (originalHeight / currentScale).toInt() } else { originalHeight } + halfHeight = layoutParams.height / 2 + + if (currentScale != DEFAULT_RATE) { + x = getPositionX(x) + y = getPositionY(y) + } else { + x = 0f + y = 0f + } + + requestLayout() + } + + fun onScaleBegin() { + if (detector.isDoubleTapping) { + detector.isQuickScaling = true + } + } + + fun onScaleEnd() { + if (scaleX < MIN_RATE) { + zoom(currentScale, MIN_RATE, x, 0f, y, 0f) + } + } + + inner class GestureListener : GestureDetectorWithLongTap.Listener() { + + override fun onSingleTapConfirmed(ev: MotionEvent): Boolean { + tapListener?.invoke(ev) + return false + } + + override fun onDoubleTap(ev: MotionEvent): Boolean { + detector.isDoubleTapping = true + return false + } + + fun onDoubleTapConfirmed(ev: MotionEvent) { + if (!isZooming) { + if (scaleX != DEFAULT_RATE) { + zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f) + } else { + val toScale = 2f + val toX = (halfWidth - ev.x) * (toScale - 1) + val toY = (halfHeight - ev.y) * (toScale - 1) + zoom(DEFAULT_RATE, toScale, 0f, toX, 0f, toY) + } + } + } + + override fun onLongTapConfirmed(ev: MotionEvent) { + val listener = longTapListener + if (listener != null && listener.invoke(ev)) { + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + } + } + + inner class Detector : GestureDetectorWithLongTap(context, listener) { + + private var scrollPointerId = 0 + private var downX = 0 + private var downY = 0 + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private var isZoomDragging = false + var isDoubleTapping = false + var isQuickScaling = false + + override fun onTouchEvent(ev: MotionEvent): Boolean { + val action = ev.actionMasked + val actionIndex = ev.actionIndex + + when (action) { + MotionEvent.ACTION_DOWN -> { + scrollPointerId = ev.getPointerId(0) + downX = (ev.x + 0.5f).toInt() + downY = (ev.y + 0.5f).toInt() + } + MotionEvent.ACTION_POINTER_DOWN -> { + scrollPointerId = ev.getPointerId(actionIndex) + downX = (ev.getX(actionIndex) + 0.5f).toInt() + downY = (ev.getY(actionIndex) + 0.5f).toInt() + } + MotionEvent.ACTION_MOVE -> { + if (isDoubleTapping && isQuickScaling) { + return true + } + + val index = ev.findPointerIndex(scrollPointerId) + if (index < 0) { + return false + } + + val x = (ev.getX(index) + 0.5f).toInt() + val y = (ev.getY(index) + 0.5f).toInt() + var dx = x - downX + var dy = if (atFirstPosition || atLastPosition) y - downY else 0 + + if (!isZoomDragging && currentScale > 1f) { + var startScroll = false + + if (abs(dx) > touchSlop) { + if (dx < 0) { + dx += touchSlop + } else { + dx -= touchSlop + } + startScroll = true + } + if (abs(dy) > touchSlop) { + if (dy < 0) { + dy += touchSlop + } else { + dy -= touchSlop + } + startScroll = true + } + + if (startScroll) { + isZoomDragging = true + } + } + + if (isZoomDragging) { + zoomScrollBy(dx, dy) + } + } + MotionEvent.ACTION_UP -> { + if (isDoubleTapping && !isQuickScaling) { + listener.onDoubleTapConfirmed(ev) + } + isZoomDragging = false + isDoubleTapping = false + isQuickScaling = false + } + MotionEvent.ACTION_CANCEL -> { + isZoomDragging = false + isDoubleTapping = false + isQuickScaling = false + } + } + return super.onTouchEvent(ev) + } + } + + private companion object { + const val ANIMATOR_DURATION_TIME = 200 + const val MIN_RATE = 0.5f + const val DEFAULT_RATE = 1f + const val MAX_SCALE_RATE = 3f + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonSubsamplingImageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonSubsamplingImageView.kt new file mode 100644 index 000000000..ccf622863 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonSubsamplingImageView.kt @@ -0,0 +1,20 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.webtoon + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView + +/** + * Implementation of subsampling scale image view that ignores all touch events, because the + * webtoon viewer handles all the gestures. + */ +class WebtoonSubsamplingImageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : SubsamplingScaleImageView(context, attrs) { + + override fun onTouchEvent(event: MotionEvent): Boolean { + return false + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonTransitionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonTransitionHolder.kt new file mode 100644 index 000000000..39ff894de --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonTransitionHolder.kt @@ -0,0 +1,155 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.webtoon + +import android.view.Gravity +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.LinearLayout +import android.widget.ProgressBar +import androidx.appcompat.widget.AppCompatButton +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.view.isNotEmpty +import androidx.core.view.isVisible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.watcher.model.EpisodeTransition +import eu.kanade.tachiyomi.ui.watcher.model.WatcherEpisode +import eu.kanade.tachiyomi.ui.watcher.viewer.WatcherTransitionView +import eu.kanade.tachiyomi.util.system.dpToPx +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers + +/** + * Holder of the webtoon viewer that contains a episode transition. + */ +class WebtoonTransitionHolder( + val layout: LinearLayout, + viewer: WebtoonViewer +) : WebtoonBaseHolder(layout, viewer) { + + /** + * Subscription for status changes of the transition page. + */ + private var statusSubscription: Subscription? = null + + private val transitionView = WatcherTransitionView(context) + + /** + * View container of the current status of the transition page. Child views will be added + * dynamically. + */ + private var pagesContainer = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + } + + init { + layout.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + layout.orientation = LinearLayout.VERTICAL + layout.gravity = Gravity.CENTER + + val paddingVertical = 48.dpToPx + val paddingHorizontal = 32.dpToPx + layout.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical) + + val childMargins = 16.dpToPx + val childParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { + setMargins(0, childMargins, 0, childMargins) + } + + layout.addView(transitionView) + layout.addView(pagesContainer, childParams) + } + + /** + * Binds the given [transition] with this view holder, subscribing to its state. + */ + fun bind(transition: EpisodeTransition) { + transitionView.bind(transition) + + transition.to?.let { observeStatus(it, transition) } + } + + /** + * Called when the view is recycled and being added to the view pool. + */ + override fun recycle() { + unsubscribeStatus() + } + + /** + * Observes the status of the page list of the next/previous episode. Whenever there's a new + * state, the pages container is cleaned up before setting the new state. + */ + private fun observeStatus(episode: WatcherEpisode, transition: EpisodeTransition) { + unsubscribeStatus() + + statusSubscription = episode.stateObserver + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { state -> + pagesContainer.removeAllViews() + when (state) { + is WatcherEpisode.State.Wait -> { + } + is WatcherEpisode.State.Loading -> setLoading() + is WatcherEpisode.State.Error -> setError(state.error, transition) + is WatcherEpisode.State.Loaded -> setLoaded() + } + pagesContainer.isVisible = pagesContainer.isNotEmpty() + } + + addSubscription(statusSubscription) + } + + /** + * Unsubscribes from the status subscription. + */ + private fun unsubscribeStatus() { + removeSubscription(statusSubscription) + statusSubscription = null + } + + /** + * Sets the loading state on the pages container. + */ + private fun setLoading() { + val progress = ProgressBar(context, null, android.R.attr.progressBarStyle) + + val textView = AppCompatTextView(context).apply { + wrapContent() + setText(R.string.transition_pages_loading) + } + + pagesContainer.addView(progress) + pagesContainer.addView(textView) + } + + /** + * Sets the loaded state on the pages container. + */ + private fun setLoaded() { + // No additional view is added + } + + /** + * Sets the error state on the pages container. + */ + private fun setError(error: Throwable, transition: EpisodeTransition) { + val textView = AppCompatTextView(context).apply { + wrapContent() + text = context.getString(R.string.transition_pages_error, error.message) + } + + val retryBtn = AppCompatButton(context).apply { + wrapContent() + setText(R.string.action_retry) + setOnClickListener { + val toEpisode = transition.to + if (toEpisode != null) { + viewer.activity.requestPreloadEpisode(toEpisode) + } + } + } + + pagesContainer.addView(textView) + pagesContainer.addView(retryBtn) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonViewer.kt new file mode 100644 index 000000000..e70d66fca --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/watcher/viewer/webtoon/WebtoonViewer.kt @@ -0,0 +1,335 @@ +package eu.kanade.tachiyomi.ui.watcher.viewer.webtoon + +import android.graphics.PointF +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.WebtoonLayoutManager +import eu.kanade.tachiyomi.ui.watcher.WatcherActivity +import eu.kanade.tachiyomi.ui.watcher.model.EpisodeTransition +import eu.kanade.tachiyomi.ui.watcher.model.WatcherPage +import eu.kanade.tachiyomi.ui.watcher.model.ViewerEpisodes +import eu.kanade.tachiyomi.ui.watcher.viewer.BaseViewer +import eu.kanade.tachiyomi.ui.watcher.viewer.ViewerNavigation.NavigationRegion +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import rx.subscriptions.CompositeSubscription +import timber.log.Timber +import kotlin.math.max +import kotlin.math.min + +/** + * Implementation of a [BaseViewer] to display pages with a [RecyclerView]. + */ +class WebtoonViewer(val activity: WatcherActivity, val isContinuous: Boolean = true) : BaseViewer { + + private val scope = MainScope() + + /** + * Recycler view used by this viewer. + */ + val recycler = WebtoonRecyclerView(activity) + + /** + * Frame containing the recycler view. + */ + private val frame = WebtoonFrame(activity) + + /** + * Layout manager of the recycler view. + */ + private val layoutManager = WebtoonLayoutManager(activity) + + /** + * Adapter of the recycler view. + */ + private val adapter = WebtoonAdapter(this) + + /** + * Distance to scroll when the user taps on one side of the recycler view. + */ + private var scrollDistance = activity.resources.displayMetrics.heightPixels * 3 / 4 + + /** + * Currently active item. It can be a episode page or a episode transition. + */ + private var currentPage: Any? = null + + /** + * Configuration used by this viewer, like allow taps, or crop image borders. + */ + val config = WebtoonConfig(scope) + + /** + * Subscriptions to keep while this viewer is used. + */ + val subscriptions = CompositeSubscription() + + init { + recycler.isVisible = false // Don't let the recycler layout yet + recycler.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) + recycler.itemAnimator = null + recycler.layoutManager = layoutManager + recycler.adapter = adapter + recycler.addOnScrollListener( + object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + onScrolled() + + if (dy < 0) { + val firstIndex = layoutManager.findFirstVisibleItemPosition() + val firstItem = adapter.items.getOrNull(firstIndex) + if (firstItem is EpisodeTransition.Prev && firstItem.to != null) { + activity.requestPreloadEpisode(firstItem.to) + } + } + } + } + ) + recycler.tapListener = f@{ event -> + if (!config.tappingEnabled) { + activity.toggleMenu() + return@f + } + + val pos = PointF(event.rawX / recycler.width, event.rawY / recycler.height) + if (!config.tappingEnabled) activity.toggleMenu() + else { + val navigator = config.navigator + when (navigator.getAction(pos)) { + NavigationRegion.MENU -> activity.toggleMenu() + NavigationRegion.NEXT, NavigationRegion.RIGHT -> scrollDown() + NavigationRegion.PREV, NavigationRegion.LEFT -> scrollUp() + } + } + } + recycler.longTapListener = f@{ event -> + if (activity.menuVisible || config.longTapEnabled) { + val child = recycler.findChildViewUnder(event.x, event.y) + if (child != null) { + val position = recycler.getChildAdapterPosition(child) + val item = adapter.items.getOrNull(position) + if (item is WatcherPage) { + activity.onPageLongTap(item) + return@f true + } + } + } + false + } + + config.imagePropertyChangedListener = { + refreshAdapter() + } + + config.navigationModeChangedListener = { + val showOnStart = config.navigationOverlayOnStart || config.forceNavigationOverlay + activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart) + } + + frame.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) + frame.addView(recycler) + } + + private fun checkAllowPreload(page: WatcherPage?): Boolean { + // Page is transition page - preload allowed + page ?: return true + + // Initial opening - preload allowed + currentPage ?: return true + + val nextItem = adapter.items.getOrNull(adapter.items.size - 1) + val nextEpisode = (nextItem as? EpisodeTransition.Next)?.to ?: (nextItem as? WatcherPage)?.episode + + // Allow preload for + // 1. Going between pages of same episode + // 2. Next episode page + return when (page.episode) { + (currentPage as? WatcherPage)?.episode -> true + nextEpisode -> true + else -> false + } + } + + /** + * Returns the view this viewer uses. + */ + override fun getView(): View { + return frame + } + + /** + * Destroys this viewer. Called when leaving the watcher or swapping viewers. + */ + override fun destroy() { + super.destroy() + scope.cancel() + subscriptions.unsubscribe() + } + + /** + * Called from the RecyclerView listener when a [page] is marked as active. It notifies the + * activity of the change and requests the preload of the next episode if this is the last page. + */ + private fun onPageSelected(page: WatcherPage, allowPreload: Boolean) { + val pages = page.episode.pages ?: return + Timber.d("onPageSelected: ${page.number}/${pages.size}") + activity.onPageSelected(page) + + // Preload next episode once we're within the last 5 pages of the current episode + val inPreloadRange = pages.size - page.number < 5 + if (inPreloadRange && allowPreload && page.episode == adapter.currentEpisode) { + Timber.d("Request preload next episode because we're at page ${page.number} of ${pages.size}") + val nextItem = adapter.items.getOrNull(adapter.items.size - 1) + val transitionEpisode = (nextItem as? EpisodeTransition.Next)?.to ?: (nextItem as?WatcherPage)?.episode + if (transitionEpisode != null) { + Timber.d("Requesting to preload episode ${transitionEpisode.episode.episode_number}") + activity.requestPreloadEpisode(transitionEpisode) + } + } + } + + /** + * Called from the RecyclerView listener when a [transition] is marked as active. It request the + * preload of the destination episode of the transition. + */ + private fun onTransitionSelected(transition: EpisodeTransition) { + Timber.d("onTransitionSelected: $transition") + val toEpisode = transition.to + if (toEpisode != null) { + Timber.d("Request preload destination episode because we're on the transition") + activity.requestPreloadEpisode(toEpisode) + } else if (transition is EpisodeTransition.Next) { + // No more episodes, show menu because the user is probably going to close the watcher + activity.showMenu() + } + } + + /** + * Tells this viewer to set the given [episodes] as active. + */ + override fun setEpisodes(episodes: ViewerEpisodes) { + Timber.d("setEpisodes") + val forceTransition = config.alwaysShowEpisodeTransition || currentPage is EpisodeTransition + adapter.setEpisodes(episodes, forceTransition) + + if (recycler.isGone) { + Timber.d("Recycler first layout") + val pages = episodes.currEpisode.pages ?: return + moveToPage(pages[min(episodes.currEpisode.requestedPage, pages.lastIndex)]) + recycler.isVisible = true + } + } + + /** + * Tells this viewer to move to the given [page]. + */ + override fun moveToPage(page: WatcherPage) { + Timber.d("moveToPage") + val position = adapter.items.indexOf(page) + if (position != -1) { + recycler.scrollToPosition(position) + if (layoutManager.findLastEndVisibleItemPosition() == -1) { + onScrolled(position) + } + } else { + Timber.d("Page $page not found in adapter") + } + } + + fun onScrolled(pos: Int? = null) { + val position = pos ?: layoutManager.findLastEndVisibleItemPosition() + val item = adapter.items.getOrNull(position) + val allowPreload = checkAllowPreload(item as? WatcherPage) + if (item != null && currentPage != item) { + currentPage = item + when (item) { + is WatcherPage -> onPageSelected(item, allowPreload) + is EpisodeTransition -> onTransitionSelected(item) + } + } + } + + /** + * Scrolls up by [scrollDistance]. + */ + private fun scrollUp() { + if (config.usePageTransitions) { + recycler.smoothScrollBy(0, -scrollDistance) + } else { + recycler.scrollBy(0, -scrollDistance) + } + } + + /** + * Scrolls down by [scrollDistance]. + */ + private fun scrollDown() { + if (config.usePageTransitions) { + recycler.smoothScrollBy(0, scrollDistance) + } else { + recycler.scrollBy(0, scrollDistance) + } + } + + /** + * Called from the containing activity when a key [event] is received. It should return true + * if the event was handled, false otherwise. + */ + override fun handleKeyEvent(event: KeyEvent): Boolean { + val isUp = event.action == KeyEvent.ACTION_UP + + when (event.keyCode) { + KeyEvent.KEYCODE_VOLUME_DOWN -> { + if (!config.volumeKeysEnabled || activity.menuVisible) { + return false + } else if (isUp) { + if (!config.volumeKeysInverted) scrollDown() else scrollUp() + } + } + KeyEvent.KEYCODE_VOLUME_UP -> { + if (!config.volumeKeysEnabled || activity.menuVisible) { + return false + } else if (isUp) { + if (!config.volumeKeysInverted) scrollUp() else scrollDown() + } + } + KeyEvent.KEYCODE_MENU -> if (isUp) activity.toggleMenu() + + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_PAGE_UP -> if (isUp) scrollUp() + + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_PAGE_DOWN -> if (isUp) scrollDown() + else -> return false + } + return true + } + + /** + * Called from the containing activity when a generic motion [event] is received. It should + * return true if the event was handled, false otherwise. + */ + override fun handleGenericMotionEvent(event: MotionEvent): Boolean { + return false + } + + /** + * Notifies adapter of changes around the current page to trigger a relayout in the recycler. + * Used when an image configuration is changed. + */ + private fun refreshAdapter() { + val position = layoutManager.findLastEndVisibleItemPosition() + adapter.notifyItemRangeChanged( + max(0, position - 3), + min(position + 3, adapter.itemCount - 1) + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/storage/AnimeFile.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/AnimeFile.kt new file mode 100644 index 000000000..bdb10543d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/storage/AnimeFile.kt @@ -0,0 +1,215 @@ +package eu.kanade.tachiyomi.util.storage + +import eu.kanade.tachiyomi.source.model.SEpisode +import eu.kanade.tachiyomi.source.model.SAnime +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import java.io.Closeable +import java.io.File +import java.io.InputStream +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipFile + +/** + * Wrapper over ZipFile to load files in epub format. + */ +class AnimeFile(file: File) : Closeable { + + /** + * Zip file of this epub. + */ + private val zip = ZipFile(file) + + /** + * Path separator used by this epub. + */ + private val pathSeparator = getPathSeparator() + + /** + * Closes the underlying zip file. + */ + override fun close() { + zip.close() + } + + /** + * Returns an input stream for reading the contents of the specified zip file entry. + */ + fun getInputStream(entry: ZipEntry): InputStream { + return zip.getInputStream(entry) + } + + /** + * Returns the zip file entry for the specified name, or null if not found. + */ + fun getEntry(name: String): ZipEntry? { + return zip.getEntry(name) + } + + /** + * Fills manga metadata using this epub file's metadata. + */ + fun fillAnimeMetadata(manga: SAnime) { + val ref = getPackageHref() + val doc = getPackageDocument(ref) + + val creator = doc.getElementsByTag("dc:creator").first() + val description = doc.getElementsByTag("dc:description").first() + + manga.author = creator?.text() + manga.description = description?.text() + } + + /** + * Fills chapter metadata using this epub file's metadata. + */ + fun fillEpisodeMetadata(chapter: SEpisode) { + val ref = getPackageHref() + val doc = getPackageDocument(ref) + + val title = doc.getElementsByTag("dc:title").first() + val publisher = doc.getElementsByTag("dc:publisher").first() + val creator = doc.getElementsByTag("dc:creator").first() + var date = doc.getElementsByTag("dc:date").first() + if (date == null) { + date = doc.select("meta[property=dcterms:modified]").first() + } + + if (title != null) { + chapter.name = title.text() + } + + if (publisher != null) { + chapter.scanlator = publisher.text() + } else if (creator != null) { + chapter.scanlator = creator.text() + } + + if (date != null) { + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()) + try { + val parsedDate = dateFormat.parse(date.text()) + if (parsedDate != null) { + chapter.date_upload = parsedDate.time + } + } catch (e: ParseException) { + // Empty + } + } + } + + /** + * Returns the path of all the images found in the epub file. + */ + fun getImagesFromPages(): List { + val ref = getPackageHref() + val doc = getPackageDocument(ref) + val pages = getPagesFromDocument(doc) + return getImagesFromPages(pages, ref) + } + + /** + * Returns the path to the package document. + */ + private fun getPackageHref(): String { + val meta = zip.getEntry(resolveZipPath("META-INF", "container.xml")) + if (meta != null) { + val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") } + val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path") + if (path != null) { + return path + } + } + return resolveZipPath("OEBPS", "content.opf") + } + + /** + * Returns the package document where all the files are listed. + */ + private fun getPackageDocument(ref: String): Document { + val entry = zip.getEntry(ref) + return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } + } + + /** + * Returns all the pages from the epub. + */ + private fun getPagesFromDocument(document: Document): List { + val pages = document.select("manifest > item") + .filter { "application/xhtml+xml" == it.attr("media-type") } + .associateBy { it.attr("id") } + + val spine = document.select("spine > itemref").map { it.attr("idref") } + return spine.mapNotNull { pages[it] }.map { it.attr("href") } + } + + /** + * Returns all the images contained in every page from the epub. + */ + private fun getImagesFromPages(pages: List, packageHref: String): List { + val result = mutableListOf() + val basePath = getParentDirectory(packageHref) + pages.forEach { page -> + val entryPath = resolveZipPath(basePath, page) + val entry = zip.getEntry(entryPath) + val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } + val imageBasePath = getParentDirectory(entryPath) + + document.allElements.forEach { + if (it.tagName() == "img") { + result.add(resolveZipPath(imageBasePath, it.attr("src"))) + } else if (it.tagName() == "image") { + result.add(resolveZipPath(imageBasePath, it.attr("xlink:href"))) + } + } + } + + return result + } + + /** + * Returns the path separator used by the epub file. + */ + private fun getPathSeparator(): String { + val meta = zip.getEntry("META-INF\\container.xml") + return if (meta != null) { + "\\" + } else { + "/" + } + } + + /** + * Resolves a zip path from base and relative components and a path separator. + */ + private fun resolveZipPath(basePath: String, relativePath: String): String { + if (relativePath.startsWith(pathSeparator)) { + // Path is absolute, so return as-is. + return relativePath + } + + var fixedBasePath = basePath.replace(pathSeparator, File.separator) + if (!fixedBasePath.startsWith(File.separator)) { + fixedBasePath = "${File.separator}$fixedBasePath" + } + + val fixedRelativePath = relativePath.replace(pathSeparator, File.separator) + val resolvedPath = File(fixedBasePath, fixedRelativePath).canonicalPath + return resolvedPath.replace(File.separator, pathSeparator).substring(1) + } + + /** + * Gets the parent directory of a path. + */ + private fun getParentDirectory(path: String): String { + val separatorIndex = path.lastIndexOf(pathSeparator) + return if (separatorIndex >= 0) { + path.substring(0, separatorIndex) + } else { + "" + } + } +} diff --git a/app/src/main/java/tachiyomi/source/AnimeSource.kt b/app/src/main/java/tachiyomi/source/AnimeSource.kt new file mode 100644 index 000000000..2ed4c2ec5 --- /dev/null +++ b/app/src/main/java/tachiyomi/source/AnimeSource.kt @@ -0,0 +1,55 @@ +package tachiyomi.source + +import tachiyomi.source.model.EpisodeInfo +import tachiyomi.source.model.AnimeInfo +import tachiyomi.source.model.Page + +/** + * A basic interface for creating a source. It could be an online source, a local source, etc... + */ +interface AnimeSource { + + /** + * Id for the source. Must be unique. + */ + val id: Long + + /** + * Name of the source. + */ + val name: String + + // TODO remove CatalogSource? + val lang: String + + /** + * Returns an observable with the updated details for a anime. + * + * @param anime the anime to update. + */ + suspend fun getAnimeDetails(anime: AnimeInfo): AnimeInfo + + /** + * Returns an observable with all the available chapters for a anime. + * + * @param anime the anime to update. + */ + suspend fun getEpisodeList(anime: AnimeInfo): List + + /** + * Returns an observable with the list of pages a chapter has. + * + * @param chapter the chapter. + */ + suspend fun getPageList(chapter: EpisodeInfo): List + + /** + * Returns a regex used to determine chapter information. + * + * @return empty regex will run default parser. + */ + fun getRegex(): Regex { + return Regex("") + } + +} diff --git a/app/src/main/res/layout/anime_controller.xml b/app/src/main/res/layout/anime_controller.xml new file mode 100644 index 000000000..5f8ded8e5 --- /dev/null +++ b/app/src/main/res/layout/anime_controller.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/anime_episodes_header.xml b/app/src/main/res/layout/anime_episodes_header.xml new file mode 100644 index 000000000..2e2ea6ef8 --- /dev/null +++ b/app/src/main/res/layout/anime_episodes_header.xml @@ -0,0 +1,39 @@ + + + + + + + + diff --git a/app/src/main/res/layout/anime_info_header.xml b/app/src/main/res/layout/anime_info_header.xml new file mode 100644 index 000000000..be19f383f --- /dev/null +++ b/app/src/main/res/layout/anime_info_header.xml @@ -0,0 +1,272 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/anime_source_comfortable_grid_item.xml b/app/src/main/res/layout/anime_source_comfortable_grid_item.xml new file mode 100644 index 000000000..66f046b9f --- /dev/null +++ b/app/src/main/res/layout/anime_source_comfortable_grid_item.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/anime_source_compact_grid_item.xml b/app/src/main/res/layout/anime_source_compact_grid_item.xml new file mode 100644 index 000000000..0641f9fe0 --- /dev/null +++ b/app/src/main/res/layout/anime_source_compact_grid_item.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/anime_source_list_item.xml b/app/src/main/res/layout/anime_source_list_item.xml new file mode 100644 index 000000000..7fb8528b7 --- /dev/null +++ b/app/src/main/res/layout/anime_source_list_item.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/episode_download_view.xml b/app/src/main/res/layout/episode_download_view.xml new file mode 100644 index 000000000..a0d90c790 --- /dev/null +++ b/app/src/main/res/layout/episode_download_view.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/episodes_item.xml b/app/src/main/res/layout/episodes_item.xml new file mode 100644 index 000000000..0836b466b --- /dev/null +++ b/app/src/main/res/layout/episodes_item.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/global_anime_search_controller.xml b/app/src/main/res/layout/global_anime_search_controller.xml new file mode 100644 index 000000000..7505a9825 --- /dev/null +++ b/app/src/main/res/layout/global_anime_search_controller.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/global_anime_search_controller_card.xml b/app/src/main/res/layout/global_anime_search_controller_card.xml new file mode 100644 index 000000000..5366a90d4 --- /dev/null +++ b/app/src/main/res/layout/global_anime_search_controller_card.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/global_anime_search_controller_card_item.xml b/app/src/main/res/layout/global_anime_search_controller_card_item.xml new file mode 100644 index 000000000..790728c6e --- /dev/null +++ b/app/src/main/res/layout/global_anime_search_controller_card_item.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/watcher_activity.xml b/app/src/main/res/layout/watcher_activity.xml new file mode 100644 index 000000000..171c50e83 --- /dev/null +++ b/app/src/main/res/layout/watcher_activity.xml @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/anime.xml b/app/src/main/res/menu/anime.xml new file mode 100644 index 000000000..8bbd39205 --- /dev/null +++ b/app/src/main/res/menu/anime.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/animelib.xml b/app/src/main/res/menu/animelib.xml new file mode 100644 index 000000000..1d4cc593e --- /dev/null +++ b/app/src/main/res/menu/animelib.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/app/src/main/res/menu/bottom_nav.xml b/app/src/main/res/menu/bottom_nav.xml index cde4691a7..0284e8ece 100644 --- a/app/src/main/res/menu/bottom_nav.xml +++ b/app/src/main/res/menu/bottom_nav.xml @@ -4,6 +4,10 @@ android:id="@+id/nav_library" android:icon="@drawable/ic_collections_bookmark_state" android:title="@string/label_library" /> + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/watcher.xml b/app/src/main/res/menu/watcher.xml new file mode 100644 index 000000000..79d1f4484 --- /dev/null +++ b/app/src/main/res/menu/watcher.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f70efed98..e45e2a0c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,6 +15,7 @@ Settings Download queue Library + Anime Updates History Sources @@ -72,11 +73,14 @@ Downloaded Next unread View chapters + View episodes Stop Pause Close Previous chapter + Previous episode Next chapter + Next episode Retry Remove Start @@ -540,6 +544,7 @@ Failed to copy to clipboard Source not installed: %1$s Add manga to library? + Add anime to library? Chapters @@ -573,6 +578,7 @@ Also apply to all manga in my library Set as default No chapters found + No episodes found AniList @@ -677,14 +683,23 @@ Checking for new chapters Update progress: %1$d/%2$d New chapters found + New episodes found For 1 title For %d titles + + For 1 title + For %d titles + 1 new chapter %1$d new chapters + + 1 new episode + %1$d new episode + Chapter %1$s Chapter %1$s and %2$d more Chapters %1$s @@ -692,6 +707,13 @@ Chapters %1$s and 1 more Chapters %1$s and %2$d more + Episode %1$s + Episode %1$s and %2$d more + Episodes %1$s + + Episodes %1$s and 1 more + Episodes %1$s and %2$d more + 1 update failed %1$d updates failed