From 5edfa7d49868d770a0c6aca9c349914eef75c3b2 Mon Sep 17 00:00:00 2001 From: clevim Date: Mon, 21 Sep 2026 20:24:36 -0300 Subject: [PATCH 1/2] feat: enhance Release Calendar with month view and additional features - Added month view toggle and functionality to ReleaseCalendarScreen. - Implemented MonthGrid composable to display releases in a calendar format. - Introduced new UI elements for toggling caught-up filter and month view. - Updated release calendar strings and added new localization entries. - Added new menu items for recommendation features in browse_source.xml. - Introduced new themes for Dracula and Alucard in themes.xml. - Enhanced SQL queries to fetch scanlators and manage manga entries. - Added unit tests for FetchInterval to ensure correct date handling. - Expanded plural and string resources for recommendations in multiple languages. --- CHANGELOG.md | 76 +++ app/build.gradle.kts | 4 +- app/src/main/java/eu/kanade/tachiyomi/App.kt | 10 + .../appwidget/ReleasesGlanceWidget.kt | 3 +- .../data/library/LibraryUpdateJob.kt | 2 + .../data/library/ReleaseDigestJob.kt | 3 +- .../data/migration/AutoMigrateJob.kt | 5 +- .../data/notification/Notifications.kt | 21 + .../data/preference/PreferencesHelper.kt | 46 +- .../data/track/myanimelist/MyAnimeListApi.kt | 2 +- .../kanade/tachiyomi/source/SourceManager.kt | 7 +- .../calendar/ReleaseCalendarController.kt | 27 +- .../kanade/tachiyomi/ui/main/MainActivity.kt | 14 + .../ui/manga/MangaDetailsController.kt | 50 +- .../ui/manga/MangaDetailsPresenter.kt | 3 + .../ui/manga/merge/MergeSearchController.kt | 35 +- .../tachiyomi/ui/reader/ReaderActivity.kt | 3 + .../tachiyomi/ui/reader/ReaderViewModel.kt | 32 +- .../ui/reader/model/ReaderChapter.kt | 3 + .../viewer/webtoon/WebtoonLayoutManager.kt | 32 +- .../viewer/webtoon/WebtoonPageHolder.kt | 1 + .../ui/reader/viewer/webtoon/WebtoonViewer.kt | 47 +- .../controllers/SettingsBrowseController.kt | 125 +++++ .../tachiyomi/ui/source/BrowseController.kt | 5 + .../tachiyomi/ui/source/SourceHolder.kt | 13 +- .../kanade/tachiyomi/ui/source/SourceItem.kt | 2 +- .../tachiyomi/ui/source/SourcePresenter.kt | 10 + .../source/browse/BrowseSourceController.kt | 155 ++++++ .../ui/source/browse/BrowseSourceHolder.kt | 3 + .../ui/source/browse/BrowseSourceItem.kt | 3 + .../source/browse/BrowseSourceListHolder.kt | 5 + .../ui/source/browse/BrowseSourcePresenter.kt | 21 +- .../globalsearch/GlobalSearchPresenter.kt | 32 +- .../util/chapter/ChapterSourceSync.kt | 20 + .../tachiyomi/util/lang/StringExtensions.kt | 13 + .../tachiyomi/util/system/LocaleHelper.kt | 1 + .../eu/kanade/tachiyomi/util/system/Themes.kt | 6 + .../main/java/karasu/core/di/DomainModule.kt | 23 +- .../data/chapter/ChapterRepositoryImpl.kt | 3 + .../domain/chapter/ChapterRepository.kt | 1 + .../domain/chapter/interactor/GetChapter.kt | 7 + .../domain/manga/interval/FetchInterval.kt | 24 +- .../manga/interval/GetReleaseSchedule.kt | 101 +++- .../domain/manga/interval/ReleaseEstimate.kt | 41 +- .../karasu/domain/migration/MangaAliases.kt | 9 +- .../recommendation/BuildRecommendations.kt | 451 ++++++++++++++++++ .../recommendation/DiscardRecommendations.kt | 31 ++ .../category/CategoryRuleScreen.kt | 106 ++-- .../library/ReleaseCalendarScreen.kt | 298 +++++++++--- app/src/main/res/menu/browse_source.xml | 24 + app/src/main/res/values/themes.xml | 19 + .../manga/interval/FetchIntervalTest.kt | 27 ++ .../sqldelight/tachiyomi/data/chapters.sq | 5 + .../sqldelight/tachiyomi/data/mangas.sq | 6 + .../moko-resources/base/plurals.xml | 40 ++ .../moko-resources/base/strings.xml | 64 +++ .../moko-resources/pt-rBR/plurals.xml | 40 ++ .../moko-resources/pt-rBR/strings.xml | 64 +++ 58 files changed, 2034 insertions(+), 190 deletions(-) create mode 100644 app/src/main/java/karasu/domain/recommendation/BuildRecommendations.kt create mode 100644 app/src/main/java/karasu/domain/recommendation/DiscardRecommendations.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a932a7db3..f2da40f3bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,85 @@ The format is simplified version of [Keep a Changelog](https://keepachangelog.co ## [Unreleased] +## [1.11.0] + +### Additions +- **Recommendations**, as a source of its own under "For you" in Browse. Learns a taste profile + from what you read (reading is the driver; tracker scores only scale it; a dropped series counts + against its tags; what you read long ago fades), then asks four public catalogues — AniList, + MangaUpdates, Kitsu and MyAnimeList — for the best-rated and most-read series under every tag + you are into, merges what they agree on (a series all four name scores highest), and finds each + one in the sources your library uses, with MangaDex alternative titles as a fallback for pt-BR + names. Your sources' own genre filters are searched too, deeper for stronger tags, and the + trackers' "similar to" suggestions for your strongest seeds round it out. Everything is read in + detail before it is shown and ranked on its full tags, tag pairs you like together, and the + community's score +- Recommendations learn from you: long-press a cover for *Not interested* (its tags lose weight, + relative to how much your library backs them) or *Already read*; adding to the library from the + window reinforces the tags it was recommended for. Verdicts can be forgotten on the tags screen +- "Your tags" screen: every learned tag with its weight and how many series back it; drag a + slider to fix a weight, add a tag, strike one that is not a genre, long-press to select and + merge tags you consider the same. Merges, bans and weights are yours and are backed up +- Recommendations are built in batches of fifty up to the number you choose (25 to 500; Settings › + Browse › Recommendations), on a schedule (every week to every six months, at your hour, Wi-Fi + only) that carries on the next day by itself when a big target needs more than one night, or on + demand: "Fetch 50 more" adds a batch, "Rebuild from scratch" starts over. Stage-by-stage + progress, a notification when done, and the list refreshes itself when the library grows by ten + or more. How far back a "recent start" may go in the catalogues is a setting too +- Recommendation filters: only with these tags (only tags something in the list carries), include + saved-but-unread, sort by best match / newest / fewest chapters, and "hide sources with + unreliable dates" (on by default). A long-press on a cover says why it is there: tags or "like + X", chapter count, last chapter, community score, and whether it is new, gone quiet or a bit + outside your usual +- Trackers enrich the library overnight: their genres and tags join the entry's own (as a custom + edit, so a refresh keeps them), and every other name they know feeds migration and merged-source + search — which is where a scanlator's own Portuguese title finally finds the series elsewhere +- Merged sources: the app looks overnight for library entries on your other sources and offers a + one-tap merge on the entry's page +- Release calendar: month view (toggle in the app bar), "Arrived today", "On hiatus", a "Only what + I'm caught up on" filter (also honoured by the widget and the morning digest), a "probably" + estimate for entries too new to have a rhythm (borrowed from the source's other series), and + tracker status (AniList, MAL, Kitsu, MangaUpdates) overriding a source that never updated its own +- Webtoon reader: "continue reading" returns to the exact spot on a tall page, a tenth of a + screen early, instead of the top of the page +- Dracula / Alucard theme (follows the system: Alucard light, Dracula dark) +- Category rules, "Your tags" and the release calendar share the app's standard app bar + +### Changes +- "Hide library items" in Browse is now on by default: what you already have on a source no + longer shows up when browsing that source +- Recommendations skip one-shots (by tag, title or chapter names), series with no chapters, and + series gone quiet for longer than their size allows (three months for a new one, up to two years + for a long one; finished series are exempt). Series from sources that report no real dates are + ranked lower and hidden by default, and their chapter count does not make them "new". Every + rebuild starts from nothing: the previous list and the rows it left behind are discarded first +- Tag names are unified across languages (Ação / Action / Acción), and source metadata dressed as + a genre ("Classificação: Sugestivo", scanlator names, years) is dropped +- Merged-source search no longer offers the entry's own source or sources already merged in, and + tries the series' other names on a source that found nothing under the library title +- A weekly series that posted late once keeps expecting its usual weekday +- One slot in five on the recommendation list goes to something a little outside your usual, so + the list keeps surprising instead of narrowing on the same five tags + ### Fixes - Tapping an extension group header did nothing: the row's ripple was on a child view, and a clickable child swallows the touch before it reaches the row the click listener is on +- Sources that report every chapter as uploaded "0 minutes ago" no longer rewrite every chapter on + each update, pollute "recently updated", or fool the release calendar and the recommendations: + such dates are treated as unknown, and the calendar learns from when chapters actually arrive +- Release calendar: a source that stamps a date as midnight UTC no longer lands a day early west + of Greenwich; webtoon "current page" was one behind when the image was taller than the screen +- Release estimates are recalculated once when the estimator changes, instead of staying wrong + until each series' next fetch +- Recommendations: a stale list could survive a rebuild that finished while the screen was + covered; a re-rank could overwrite a batch the build had just written; looking a title up in the + sources ran one source at a time with no timeout, so one silent source could hold a build for + an hour; "Fetch 50 more" did nothing once the target was met +- Tag names from sources and from your own typing are sanitised before they are sent to AniList + or Kitsu + +### Other +- Chapter memo is treated as source-owned; per-chapter reading offsets live in a preference ## [1.10.3] diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2e1c91f3b8..540be4ede1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,7 +19,7 @@ fun runCommand(command: String): String { } @Suppress("PropertyName") -val _versionName = "1.10.3" +val _versionName = "1.11.0" val betaCount by lazy { val betaTags = runCommand("git tag -l --sort=refname v${_versionName}-b*") @@ -47,7 +47,7 @@ base { android { defaultConfig { applicationId = "eu.kanade.tachiyomi" - versionCode = 162 + versionCode = 163 versionName = _versionName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" multiDexEnabled = true diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 1a681207bc..ba8e736c3d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -61,6 +61,8 @@ import eu.kanade.tachiyomi.util.system.AuthenticatorUtil import eu.kanade.tachiyomi.util.system.GLUtil import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.launchIO +import karasu.domain.manga.interval.ReleaseEstimate +import karasu.domain.manga.interval.RecalculateReleaseEstimates import eu.kanade.tachiyomi.util.system.localeContext import eu.kanade.tachiyomi.util.system.notification import eu.kanade.tachiyomi.util.system.setToDefault @@ -153,6 +155,14 @@ open class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.F with(TachiyomiWidgetManager()) { this@App.init() } } + // The release estimator changed since these estimates were written: redo them once. + if (preferences.releaseEstimateVersion().get() != ReleaseEstimate.ESTIMATOR_VERSION) { + scope.launchIO { + runCatching { Injekt.get().await() } + .onSuccess { preferences.releaseEstimateVersion().set(ReleaseEstimate.ESTIMATOR_VERSION) } + } + } + // Show notification to disable Incognito Mode when it's enabled preferences.incognitoMode().changes() .onEach { enabled -> diff --git a/app/src/main/java/eu/kanade/tachiyomi/appwidget/ReleasesGlanceWidget.kt b/app/src/main/java/eu/kanade/tachiyomi/appwidget/ReleasesGlanceWidget.kt index fb58261578..3f32d1682a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/appwidget/ReleasesGlanceWidget.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/appwidget/ReleasesGlanceWidget.kt @@ -109,7 +109,8 @@ class ReleasesGlanceWidget : GlanceAppWidget() { .toSet() val grace = ReleaseEstimate.graceOf(preferences.releaseMissGraceDays().get()) val calendar = runCatching { - getReleaseSchedule.await(categories).calendar(dayCount = 7, grace = grace) + getReleaseSchedule.await(categories, onlyCaughtUp = preferences.releaseCalendarOnlyCaughtUp().get()) + .calendar(dayCount = 7, grace = grace) } .getOrNull() ?: return ReleasesWidgetData(today = true, covers = emptyList()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt index 339fcb0187..3c9518efc1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt @@ -31,6 +31,7 @@ import eu.kanade.tachiyomi.data.download.DownloadJob import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.koreader.KoreaderSyncJob import eu.kanade.tachiyomi.data.migration.AutoMigrateJob +import eu.kanade.tachiyomi.data.recommendation.RecommendationJob import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.DEVICE_BATTERY_NOT_LOW import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING @@ -860,6 +861,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet // do with how often the library is refreshed. Its own hour is its off switch, and // re-registering here is what survives a reboot or an app update. AutoMigrateJob.setupTask(context) + RecommendationJob.setupTask(context) } fun cancelAllWorks(context: Context) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/ReleaseDigestJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/ReleaseDigestJob.kt index 8ea3566552..c44092d82a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/ReleaseDigestJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/ReleaseDigestJob.kt @@ -50,7 +50,8 @@ class ReleaseDigestJob(private val context: Context, workerParams: WorkerParamet // Day one of the calendar rather than a fresh query, so the notification and the screen // it opens can never disagree — including about overdue entries, which the calendar puts // on today because the chapter has not arrived and is therefore still coming. - val today = Injekt.get().await(categories) + val schedule = Injekt.get().await(categories, onlyCaughtUp = preferences.releaseCalendarOnlyCaughtUp().get()) + val today = schedule .calendar( dayCount = 1, grace = ReleaseEstimate.graceOf(preferences.releaseMissGraceDays().get()), diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/migration/AutoMigrateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/migration/AutoMigrateJob.kt index badf66b547..11db44f63a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/migration/AutoMigrateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/migration/AutoMigrateJob.kt @@ -23,6 +23,7 @@ import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.smartsearch.SmartSearchEngine import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.RecommendationSource import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.ui.main.MainActivity @@ -143,7 +144,8 @@ class AutoMigrateJob(private val context: Context, workerParams: WorkerParameter * confident about, so listing pt-BR before English is what makes pt-BR win a tie. */ private fun candidateSources(languages: List, exclude: Long): List { - val installed = sourceManager.getCatalogueSources().filter { it.id != exclude } + val installed = sourceManager.getCatalogueSources() + .filter { it.id != exclude && it.id != RecommendationSource.ID } return languages.flatMap { lang -> installed.filter { it.lang.equals(lang, ignoreCase = true) } } } @@ -296,7 +298,6 @@ class AutoMigrateJob(private val context: Context, workerParams: WorkerParameter * twice a year and never walk it back. Landing exactly on the hour counts as passed, so * rescheduling at the moment the job fires books the next slot, not the same one again. */ - @VisibleForTesting fun millisUntil(day: Int, hour: Int, now: ZonedDateTime = ZonedDateTime.now()): Long { val todayAt = now.truncatedTo(ChronoUnit.DAYS).withHour(hour) var next = if (todayAt.isAfter(now)) todayAt else todayAt.plusDays(1) 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 ec84e49082..f74886ec27 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 @@ -71,6 +71,12 @@ object Notifications { * library moved on its own. That is worth being able to keep loud while muting the rest. */ const val CHANNEL_AUTO_MIGRATE = "auto_migrate_channel" + + /** A rebuild of the recommendations the reader asked for has finished. */ + const val CHANNEL_RECOMMENDATIONS = "recommendations_channel" + const val ID_RECOMMENDATIONS = -107 + const val CHANNEL_RECOMMENDATIONS_PROGRESS = "recommendations_progress_channel" + const val ID_RECOMMENDATIONS_PROGRESS = -108 const val ID_AUTO_MIGRATE = -106 /** @@ -199,6 +205,21 @@ object Notifications { ).apply { group = GROUP_LIBRARY }, + NotificationChannel( + CHANNEL_RECOMMENDATIONS, + context.getString(MR.strings.recommendations), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + group = GROUP_LIBRARY + }, + NotificationChannel( + CHANNEL_RECOMMENDATIONS_PROGRESS, + context.getString(MR.strings.recommendation_refreshing), + NotificationManager.IMPORTANCE_LOW, + ).apply { + group = GROUP_LIBRARY + setShowBadge(false) + }, NotificationChannel( CHANNEL_BACKUP_RESTORE_PROGRESS, context.getString(MR.strings.progress), 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 bb4f89f3d7..4ac952f3a5 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 @@ -245,6 +245,50 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto */ fun releaseScheduleCategories() = preferenceStore.getStringSet("release_schedule_categories", emptySet()) + /** Calendar as a month grid rather than the list of days. */ + fun releaseCalendarMonthView() = preferenceStore.getBoolean("release_calendar_month_view", false) + + /** Calendar, widget and digest leave out entries the reader is behind on. */ + fun releaseCalendarOnlyCaughtUp() = preferenceStore.getBoolean("release_calendar_only_caught_up", false) + + /** The estimator whose output the stored estimates came from; a newer one recalculates once. */ + fun releaseEstimateVersion() = preferenceStore.getInt(Preference.appStateKey("release_estimate_version"), 0) + + /** JSON object of chapter id to how far into its last read page the reader was (0..1). */ + fun readerPageOffsets() = preferenceStore.getString("reader_page_offsets", "{}") + + /** Hour of the day the recommendations are rebuilt, or -1 for never. */ + fun recommendationHour() = preferenceStore.getInt("recommendation_hour", 3) + + /** Library categories the taste profile is learned from. Empty means the whole library. */ + fun recommendationCategories() = preferenceStore.getStringSet("recommendation_categories", emptySet()) + + /** How many recommendations to build up to. Bigger takes more nights; the job resumes itself. */ + fun recommendationTarget() = preferenceStore.getInt("recommendation_target", 150) + + /** How far back a series may have started to count as a recent start in the catalogue pass. */ + fun recommendationRecentYears() = preferenceStore.getInt("recommendation_recent_years", 3) + + /** Days between rebuilds. */ + fun recommendationIntervalDays() = preferenceStore.getInt("recommendation_interval_days", 15) + + fun recommendationOnlyOnWifi() = preferenceStore.getBoolean("recommendation_only_on_wifi", true) + + /** JSON object of lowercase canonical tag to the name of the group the reader merged it into. */ + fun recommendationTagMerges() = preferenceStore.getString("recommendation_tag_merges", "{}") + + /** JSON list of the reader's verdicts on recommendations. A preference so it is backed up. */ + fun recommendationFeedback() = preferenceStore.getString("recommendation_feedback", "[]") + + /** JSON object of manga id to what its trackers know (tags, other names). Backed up with the rest. */ + fun recommendationTrackerExtras() = preferenceStore.getString("recommendation_tracker_extras", "{}") + + /** Tags the reader struck from the profile for good: things a source called a genre that are not one. */ + fun recommendationBannedTags() = preferenceStore.getStringSet("recommendation_banned_tags", emptySet()) + + /** JSON object of tag to weight (-1..1): the reader's overrides on the learned taste profile. */ + fun recommendationTagWeights() = preferenceStore.getString("recommendation_tag_weights", "{}") + fun libraryUpdateMangaRestriction() = preferenceStore.getStringSet("library_update_manga_restriction", setOf(MANGA_HAS_UNREAD, MANGA_NON_COMPLETED, MANGA_NON_READ)) fun libraryUpdateCategories() = preferenceStore.getStringSet("library_update_categories", emptySet()) @@ -423,7 +467,7 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto fun onlySearchPinned() = preferenceStore.getBoolean(Keys.onlySearchPinned, false) - fun hideInLibraryItems() = preferenceStore.getBoolean("browse_hide_in_library_items", false) + fun hideInLibraryItems() = preferenceStore.getBoolean("browse_hide_in_library_items", true) // Tutorial preferences fun shownFilterTutorial() = preferenceStore.getBoolean("shown_filter_tutorial", false) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt index ccdb60d17d..294b483148 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt @@ -244,7 +244,7 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI // Karasu's own MyAnimeList client, registered as an android app: public, no secret, PKCE. // Its single registered redirect is karasu://myanimelist-auth, which is why neither the // authorize URL nor the token exchange sends redirect_uri. - private const val CLIENT_ID = "2fd75c9c4d50cd157b1479216400f7f5" + const val CLIENT_ID = "2fd75c9c4d50cd157b1479216400f7f5" private const val BASE_OAUTH_URL = "https://myanimelist.net/v1/oauth2" private const val BASE_API_URL = "https://api.myanimelist.net/v2" diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt index b5586eabe8..d76276b520 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt @@ -44,7 +44,12 @@ class SourceManager( scope.launch { extensionManager.installedExtensionsFlow .collectLatest { extensions -> - val mutableMap = ConcurrentHashMap(mapOf(LocalSource.ID to LocalSource(context))) + val mutableMap = ConcurrentHashMap( + mapOf( + LocalSource.ID to LocalSource(context), + RecommendationSource.ID to RecommendationSource(context), + ), + ) extensions.forEach { extension -> extension.sources.forEach { mutableMap[it.id] = it diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/calendar/ReleaseCalendarController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/calendar/ReleaseCalendarController.kt index f2499ca788..d71b8f54ca 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/calendar/ReleaseCalendarController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/calendar/ReleaseCalendarController.kt @@ -20,6 +20,9 @@ import karasu.domain.manga.interval.ReleaseCalendar import karasu.domain.manga.interval.ReleaseEstimate import karasu.domain.manga.interval.ReleaseSchedule import karasu.domain.manga.interval.calendar +import karasu.domain.manga.interval.month +import karasu.domain.manga.interval.ReleaseMonth +import java.time.YearMonth import karasu.presentation.library.ReleaseCalendarScreen import uy.kohesive.injekt.injectLazy @@ -37,6 +40,9 @@ class ReleaseCalendarController : BaseComposeController() { private var schedule by mutableStateOf(null) private var calendar by mutableStateOf(null) + private var month by mutableStateOf(null) + private var monthView by mutableStateOf(preferences.releaseCalendarMonthView().get()) + private var onlyCaughtUp by mutableStateOf(preferences.releaseCalendarOnlyCaughtUp().get()) /** * Reloaded on every attach and every resume rather than once per composition. @@ -51,12 +57,11 @@ class ReleaseCalendarController : BaseComposeController() { val categories = preferences.releaseScheduleCategories().get() .mapNotNull { it.toIntOrNull() } .toSet() - val loaded = withIOContext { getReleaseSchedule.await(categories) } + val loaded = withIOContext { getReleaseSchedule.await(categories, onlyCaughtUp = onlyCaughtUp) } + val grace = ReleaseEstimate.graceOf(preferences.releaseMissGraceDays().get()) schedule = loaded - calendar = loaded.calendar( - dayCount = DAYS_SHOWN, - grace = ReleaseEstimate.graceOf(preferences.releaseMissGraceDays().get()), - ) + calendar = loaded.calendar(dayCount = DAYS_SHOWN, grace = grace) + month = loaded.month(YearMonth.now(), grace = grace) } } @@ -75,6 +80,18 @@ class ReleaseCalendarController : BaseComposeController() { ReleaseCalendarScreen( schedule = schedule, calendar = calendar, + month = month, + monthView = monthView, + onMonthViewChange = { + monthView = it + preferences.releaseCalendarMonthView().set(it) + }, + onlyCaughtUp = onlyCaughtUp, + onOnlyCaughtUpChange = { + onlyCaughtUp = it + preferences.releaseCalendarOnlyCaughtUp().set(it) + load() + }, contentPadding = PaddingValues(bottom = 32.dp), onMangaClick = { manga -> manga.id?.let { router.pushController(MangaDetailsController(it).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 418acb08af..abe0b07f6e 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 @@ -103,6 +103,9 @@ import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate import eu.kanade.tachiyomi.ui.setting.SettingsLegacyController import eu.kanade.tachiyomi.ui.setting.controllers.SettingsMainController import eu.kanade.tachiyomi.ui.source.BrowseController +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.RecommendationSource +import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.source.browse.BrowseSourceController import eu.kanade.tachiyomi.ui.source.globalsearch.GlobalSearchController import eu.kanade.tachiyomi.util.manga.MangaCoverMetadata @@ -147,6 +150,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import karasu.core.migration.Migrator import karasu.domain.base.BasePreferences @@ -1068,6 +1073,14 @@ open class MainActivity : BaseActivity() { router.pushController(ReleaseCalendarController().withFadeTransaction()) } } + SHORTCUT_RECOMMENDATIONS -> { + nav.selectedItemId = R.id.nav_browse + nav.post { + (Injekt.get().get(RecommendationSource.ID) as? CatalogueSource)?.let { + router.pushController(BrowseSourceController(it).withFadeTransaction()) + } + } + } SHORTCUT_MIGRATE_REVIEW -> { // The targets the nightly pass found but would not apply on its own. Same review // screen as a hand-started migration, with the picks already filled in. @@ -1652,6 +1665,7 @@ open class MainActivity : BaseActivity() { // Shortcut actions const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY" const val SHORTCUT_RELEASE_CALENDAR = "eu.kanade.tachiyomi.SHOW_RELEASE_CALENDAR" + const val SHORTCUT_RECOMMENDATIONS = "eu.kanade.tachiyomi.SHOW_RECOMMENDATIONS" const val SHORTCUT_RECENTLY_UPDATED = "eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED" const val SHORTCUT_RECENTLY_READ = "eu.kanade.tachiyomi.SHOW_RECENTLY_READ" const val SHORTCUT_BROWSE = "eu.kanade.tachiyomi.SHOW_BROWSE" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt index 09deff328b..b61d7829d0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt @@ -92,6 +92,10 @@ import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersSortBottomSheet import eu.kanade.tachiyomi.ui.manga.chapter.languageSources import eu.kanade.tachiyomi.ui.manga.chapter.showChapterLanguageDialog import eu.kanade.tachiyomi.ui.manga.merge.MergeSearchController +import karasu.domain.migration.MangaAliases +import karasu.domain.recommendation.MergeSuggestionsStore +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import eu.kanade.tachiyomi.ui.manga.merge.MergedSourceRow import eu.kanade.tachiyomi.ui.manga.merge.showMergedSourcesDialog import eu.kanade.tachiyomi.ui.manga.track.TrackItem @@ -496,6 +500,31 @@ class MangaDetailsController : } } + /** + * The nightly pass found this series on another of the reader's sources: one tap merges it. + * Offered once per entry and source, whatever the answer. + */ + private fun offerMergeSuggestion(view: View) { + val manga = manga ?: return + if (!manga.favorite) return + val store = Injekt.get() + val suggestion = store.pending(manga.id).firstOrNull() ?: return + val sourceName = presenter.sourceManager.get(suggestion.sourceId)?.name ?: return + val mangaId = manga.id ?: return + snack = view.snack(view.context.getString(MR.strings.merge_suggestion, sourceName), Snackbar.LENGTH_LONG) { + setAction(MR.strings.merge_suggestion_action) { presenter.addMergedSource(suggestion.sourceId, suggestion.url) } + // Either way it is not offered again: merged, it is in; waved away, the reader said no. + // The nightly look would otherwise keep serving the same find until its recheck. + addCallback( + object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + store.ignore(mangaId, suggestion.sourceId) + } + }, + ) + } + } + override fun onDestroyView(view: View) { snack?.dismiss() snack = null @@ -909,7 +938,11 @@ class MangaDetailsController : } } + /** Offered once per screen, the first time the entry is on screen. */ + private var mergeOffered = false + fun updateHeader() { + if (!mergeOffered) view?.let { mergeOffered = true; offerMergeSuggestion(it) } view ?: return binding.swipeRefresh.isRefreshing = presenter.isLoading adapter?.setChapters(presenter.chapters) @@ -1900,11 +1933,18 @@ class MangaDetailsController : .show() }, onAdd = { - router.pushController( - MergeSearchController(presenter.manga) - .apply { targetController = this@MangaDetailsController } - .withFadeTransaction(), - ) + val manga = presenter.manga + viewScope.launchIO { + val excluded = presenter.mergedSourceIds() + manga.source + val aliases = MangaAliases(Injekt.get(), Injekt.get()).local(manga) + withUIContext { + router.pushController( + MergeSearchController(manga, excluded, aliases) + .apply { targetController = this@MangaDetailsController } + .withFadeTransaction(), + ) + } + } }, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt index e60b4410d9..cd399fe6a0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt @@ -797,6 +797,9 @@ class MangaDetailsPresenter( * The row for the other source already exists — global search inserts every result it * shows — so this only records the link and pulls that row's chapters in. */ + /** The sources already merged into this entry. */ + suspend fun mergedSourceIds(): Set = mergedSources.await(manga.id!!).map { it.source }.toSet() + fun addMergedSource(source: Long, url: String) { presenterScope.launchIO { if (!mergedSources.addAtEnd(mangaId, source, url, ownSource = manga.source)) return@launchIO diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchController.kt index bb73afb6dd..667efb221e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchController.kt @@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.ui.manga.MangaDetailsController import eu.kanade.tachiyomi.ui.source.globalsearch.GlobalSearchCardAdapter import eu.kanade.tachiyomi.ui.source.globalsearch.GlobalSearchController +import eu.kanade.tachiyomi.ui.source.globalsearch.GlobalSearchPresenter /** * Global search that merges the picked result into an existing manga instead of opening it. @@ -16,15 +17,41 @@ import eu.kanade.tachiyomi.ui.source.globalsearch.GlobalSearchController */ class MergeSearchController( initialQuery: String? = null, + excludedSources: Set = emptySet(), + aliases: List = emptyList(), ) : GlobalSearchController( initialQuery, - bundle = bundleOf(QUERY to initialQuery), + bundle = bundleOf( + QUERY to initialQuery, + EXCLUDED to excludedSources.toLongArray(), + ALIASES to aliases.toTypedArray(), + ), ) { @Suppress("unused") - constructor(bundle: Bundle) : this(bundle.getString(QUERY)) + constructor(bundle: Bundle) : this( + bundle.getString(QUERY), + bundle.getLongArray(EXCLUDED)?.toSet().orEmpty(), + bundle.getStringArray(ALIASES)?.toList().orEmpty(), + ) - constructor(manga: Manga) : this(manga.originalTitle) + /** + * @param excludedSources the series' own source and the ones already merged in: offering + * them again would only offer the same chapters twice. + * @param aliases the other names the series goes by, tried on a source that found nothing + * under the library title. + */ + constructor(manga: Manga, excludedSources: Set, aliases: List) : + this(manga.originalTitle, excludedSources, aliases) + + // Runs from the base constructor, before this class's own fields exist: everything it needs + // has to come from the bundle, which is why the constructor puts it there. + override fun createPresenter() = GlobalSearchPresenter( + initialQuery, + extensionFilter, + excludedSources = args.getLongArray(EXCLUDED)?.toSet().orEmpty(), + fallbackQueries = args.getStringArray(ALIASES)?.toList().orEmpty(), + ) override fun onMangaClick(manga: Manga) { val target = targetController as? MangaDetailsController ?: return @@ -40,5 +67,7 @@ class MergeSearchController( companion object { const val QUERY = "query" + private const val EXCLUDED = "excluded" + private const val ALIASES = "aliases" } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index 189c849560..9486000ed5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -1539,6 +1539,9 @@ class ReaderActivity : BaseActivity() { * bottom menu and delegates the change to the view model. */ @SuppressLint("SetTextI18n") + /** The webtoon viewer's report of how far down the current page the screen is. */ + fun onPageOffsetChanged(page: ReaderPage, fraction: Float) = viewModel.onPageOffsetChanged(page, fraction) + fun onPageSelected(page: ReaderPage, hasExtraPage: Boolean) { viewModel.onPageSelected(page, hasExtraPage) val pages = page.chapter.pages ?: return diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 3b66dee3ea..b4ecc0e22d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -108,6 +108,7 @@ class ReaderViewModel( private val downloadManager: DownloadManager = Injekt.get(), private val coverCache: CoverCache = Injekt.get(), private val preferences: PreferencesHelper = Injekt.get(), + private val pageOffsets: PageOffsetStore = PageOffsetStore(Injekt.get()), private val chapterFilter: ChapterFilter = Injekt.get(), private val storageManager: StorageManager = Injekt.get(), private val downloadPreferences: DownloadPreferences = Injekt.get(), @@ -199,7 +200,7 @@ class ReaderViewModel( .onEach { currentChapter -> chapterId = currentChapter.chapter.id!! if (secondRun || !currentChapter.chapter.read) { - currentChapter.requestedPage = currentChapter.chapter.last_page_read + currentChapter.requestLastRead() } secondRun = true } @@ -740,8 +741,31 @@ class ReaderViewModel( * Saves this [readerChapter]'s progress (last read page and whether it's read). * If incognito mode isn't on or has at least 1 tracker */ + /** Where the reader left this chapter: the page the app always saved, and how far into it. */ + private fun ReaderChapter.requestLastRead() { + requestedPage = chapter.last_page_read + requestedOffset = pageOffsets.get(chapter.id) + } + + /** + * How far into the current page the screen is, as last reported by the viewer. Kept in + * memory and written with the page — or on leaving, since a strip several screens tall is + * read for minutes without the page ever changing. + */ + private var pageOffset: Pair? = null + + fun onPageOffsetChanged(page: ReaderPage, fraction: Float) { + pageOffset = page.chapter.chapter.id!! to fraction + } + + private fun savePageOffset(chapterId: Long) { + if (preferences.incognitoMode().get()) return + val (id, fraction) = pageOffset ?: return + pageOffsets.set(chapterId, if (id == chapterId) fraction else 0f) + } + private suspend fun saveChapterProgress(readerChapter: ReaderChapter, page: ReaderPage, hasExtraPage: Boolean) { - readerChapter.requestedPage = readerChapter.chapter.last_page_read + readerChapter.requestLastRead() getChapter.awaitById(readerChapter.chapter.id!!)?.let { dbChapter -> readerChapter.chapter.bookmark = dbChapter.bookmark } @@ -750,6 +774,7 @@ class ReaderViewModel( if (shouldTrack && page.status !is Page.State.Error) { readerChapter.chapter.last_page_read = page.index readerChapter.chapter.pages_left = (readerChapter.pages?.size ?: page.index) - page.index + savePageOffset(readerChapter.chapter.id!!) // For double pages, check if the second to last page is doubled up if ( (readerChapter.pages?.lastIndex == page.index && page.firstHalf != true) || @@ -803,6 +828,7 @@ class ReaderViewModel( fun flushReadTimer() { getCurrentChapter()?.let { + it.chapter.id?.let(::savePageOffset) viewModelScope.launchNonCancellableIO { saveChapterHistory(it) } @@ -885,7 +911,7 @@ class ReaderViewModel( if (currChapters != null) { // Save current page val currChapter = currChapters.currChapter - currChapter.requestedPage = currChapter.chapter.last_page_read + currChapter.requestLastRead() mutableState.update { it.copy( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt index f4424e0e59..f2805fcfd9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt @@ -21,6 +21,9 @@ data class ReaderChapter(val chapter: Chapter) { var requestedPage: Int = 0 + /** How far into [requestedPage] to land, 0..1. Only the webtoon viewer honours it. */ + var requestedOffset: Float = 0f + private var references = 0 fun ref() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt index 7cb1ab0dfc..5d505db0ff 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt @@ -28,24 +28,26 @@ class WebtoonLayoutManager(activity: ReaderActivity, private val extraLayoutSpac } /** - * Returns the position of the last item whose end side is visible on screen. + * Returns the position of the last item whose end side is visible on screen, or of the item + * spanning the whole screen when one image is taller than it. + * + * Without the second case a page taller than the screen was never "current": its end was + * off screen, so the page before it was reported instead, and that is what got saved as the + * reading position. Ported from Mihon (mihonapp/mihon#562). */ 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) + val callback = if (mOrientation == HORIZONTAL) mHorizontalBoundCheck else mVerticalBoundCheck + val start = callback.mCallback.parentStart + val end = callback.mCallback.parentEnd + for (i in childCount - 1 downTo 0) { + val child = getChildAt(i)!! + val childStart = callback.mCallback.getChildStart(child) + val childEnd = callback.mCallback.getChildEnd(child) + if (childEnd <= end || childStart < start) { + return getPosition(child) + } } - - return if (child == null) NO_POSITION else getPosition(child) + return NO_POSITION } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt index 6cdc1d4311..513b10d9f3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt @@ -251,6 +251,7 @@ class WebtoonPageHolder( progressContainer.isVisible = false removeErrorLayout() addTranslationsView() + page?.let(viewer::onPageDecoded) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt index 5374755cc3..ca843bf243 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt @@ -8,6 +8,7 @@ import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT import androidx.core.view.isGone +import androidx.core.view.doOnLayout import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.WebtoonLayoutManager @@ -234,7 +235,9 @@ class WebtoonViewer(val activity: ReaderActivity, val hasMargins: Boolean = fals if (recycler.isGone) { Logger.d { "Recycler first layout" } val pages = chapters.currChapter.pages ?: return - moveToPage(pages[min(chapters.currChapter.requestedPage, pages.lastIndex)]) + val page = pages[min(chapters.currChapter.requestedPage, pages.lastIndex)] + pendingOffset = chapters.currChapter.requestedOffset.takeIf { it > 0f }?.let { page to it } + moveToPage(page) recycler.isVisible = true } } @@ -255,12 +258,51 @@ class WebtoonViewer(val activity: ReaderActivity, val hasMargins: Boolean = fals } } + /** + * How far down the item at [position] the top of the screen is, 0..1. Zero when the item + * starts on screen: resuming at its top is then at most a few lines early, never late. + */ + private fun offsetOf(position: Int): Float { + val child = layoutManager.findViewByPosition(position) ?: return 0f + if (child.height <= 0) return 0f + return ((layoutManager.paddingTop - child.top).toFloat() / child.height).coerceIn(0f, 1f) + } + + /** + * The page to land partway down once its image is decoded, and how far. Set on first layout + * and applied from [onPageDecoded]: before the decode the item has no height to scroll into. + */ + private var pendingOffset: Pair? = null + + fun onPageDecoded(page: ReaderPage) { + val (pending, fraction) = pendingOffset ?: return + if (pending != page) return + pendingOffset = null + val position = adapter.items.indexOf(page).takeIf { it != -1 } ?: return + val child = layoutManager.findViewByPosition(position) ?: return + // The decode may still have a layout pending that gives the item its real height, and a + // scroll must not be asked for from inside that layout. + child.doOnLayout { + recycler.post { + // A tenth of a screen of leeway before the saved spot, so the resume is never past it. + val offset = fraction * it.height - recycler.height * RESUME_LEEWAY + layoutManager.scrollToPositionWithOffset(position, -offset.toInt().coerceAtLeast(0)) + onScrolled() + } + } + } + fun onScrolled(pos: Int? = null) { val position = pos ?: layoutManager.findLastEndVisibleItemPosition() val item = adapter.items.getOrNull(position) val allowPreload = checkAllowPreload(item as? ReaderPage) + if (item is ReaderPage && pendingOffset == null) { + activity.onPageOffsetChanged(item, offsetOf(position)) + } if (item != null && currentPage != item) { currentPage = item + // Moved on before the saved spot could be applied: it no longer applies. + if (pendingOffset?.first != item) pendingOffset = null when (item) { is ReaderPage -> onPageSelected(item, allowPreload) is ChapterTransition -> onTransitionSelected(item) @@ -342,3 +384,6 @@ class WebtoonViewer(val activity: ReaderActivity, val hasMargins: Boolean = fals } private const val RECYCLER_VIEW_CACHE_SIZE = 4 + +/** Fraction of the screen to land above the saved spot when resuming. */ +private const val RESUME_LEEWAY = 0.1f diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/SettingsBrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/SettingsBrowseController.kt index b445a7dce2..33a945cb12 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/SettingsBrowseController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/SettingsBrowseController.kt @@ -13,6 +13,15 @@ import karasu.i18n.MR import karasu.util.lang.getString import dev.icerock.moko.resources.compose.stringResource import eu.kanade.tachiyomi.data.migration.AutoMigrateJob +import eu.kanade.tachiyomi.data.recommendation.RecommendationJob +import eu.kanade.tachiyomi.data.recommendation.RecommendationJob.Companion.label +import karasu.domain.recommendation.BuildProgress +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.Injekt +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.launchIn +import karasu.domain.recommendation.RecommendationStore +import android.text.format.DateUtils import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferenceKeys import eu.kanade.tachiyomi.data.preference.changesIn @@ -32,6 +41,10 @@ import eu.kanade.tachiyomi.ui.setting.onChange import eu.kanade.tachiyomi.ui.setting.onClick import eu.kanade.tachiyomi.ui.setting.preference import eu.kanade.tachiyomi.ui.setting.preferenceCategory +import karasu.domain.category.interactor.GetCategories +import kotlinx.coroutines.runBlocking +import eu.kanade.tachiyomi.ui.setting.multiSelectListPreferenceMat +import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.ui.setting.summaryMRes as summaryRes import eu.kanade.tachiyomi.ui.setting.switchPreference import eu.kanade.tachiyomi.ui.setting.titleMRes as titleRes @@ -39,6 +52,8 @@ import eu.kanade.tachiyomi.util.lang.addBetaTag import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.formatHourOfDay import eu.kanade.tachiyomi.util.system.launchUI +import eu.kanade.tachiyomi.util.system.withUIContext +import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.view.setAction import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.withFadeTransaction @@ -51,6 +66,8 @@ import karasu.presentation.extension.repo.ExtensionRepoController class SettingsBrowseController : SettingsLegacyController() { + private val getCategories: GetCategories by injectLazy() + /** * Languages that actually have an installed source, sorted by name. * @@ -75,6 +92,7 @@ class SettingsBrowseController : SettingsLegacyController() { switchPreference { bindTo(preferences.hideInLibraryItems()) titleRes = MR.strings.hide_in_library_items + defaultValue = true } } @@ -300,6 +318,109 @@ class SettingsBrowseController : SettingsLegacyController() { } infoPreference(MR.strings.does_not_prevent_unofficial_nsfw) } + + preferenceCategory { + titleRes = MR.strings.recommendations + + // What gets built. + intListPreference(activity) { + bindTo(preferences.recommendationTarget()) + titleRes = MR.strings.recommendation_target + entries = RECOMMENDATION_TARGETS.map { context.getString(MR.plurals.recommendation_target_count, it, it) } + entryValues = RECOMMENDATION_TARGETS + defaultValue = 150 + } + + multiSelectListPreferenceMat(activity) { + bindTo(preferences.recommendationCategories()) + titleRes = MR.strings.recommendation_categories + summaryRes = MR.strings.recommendation_categories_summary + + // FIXME: Don't do blocking (same as the library settings) + val categories = listOf(Category.createDefault(context)) + runBlocking { getCategories.await() } + entries = categories.map { it.name } + entryValues = categories.map { it.id.toString() } + noSelectionRes = MR.strings.all + } + + intListPreference(activity) { + bindTo(preferences.recommendationRecentYears()) + titleRes = MR.strings.recommendation_recent_years + entries = RECOMMENDATION_RECENT_YEARS.map { context.getString(MR.plurals.recommendation_years, it, it) } + entryValues = RECOMMENDATION_RECENT_YEARS + defaultValue = 3 + } + + // When it gets built. + intListPreference(activity) { + bindTo(preferences.recommendationHour()) + titleRes = MR.strings.recommendation_update_hour + entries = listOf(context.getString(MR.strings.auto_migration_off)) + + MIGRATE_HOURS.map { context.formatHourOfDay(it) } + entryValues = listOf(-1) + MIGRATE_HOURS + defaultValue = 3 + + onChange { + viewScope.launchUI { RecommendationJob.setupTask(context) } + true + } + } + + intListPreference(activity) { + bindTo(preferences.recommendationIntervalDays()) + titleRes = MR.strings.recommendation_update_every + entries = RECOMMENDATION_INTERVALS.map { context.getString(MR.plurals.recommendation_every_days, it, it) } + entryValues = RECOMMENDATION_INTERVALS + defaultValue = 15 + + preferences.recommendationHour().changesIn(viewScope) { isVisible = it >= 0 } + + onChange { + viewScope.launchUI { RecommendationJob.setupTask(context) } + true + } + } + + switchPreference { + bindTo(preferences.recommendationOnlyOnWifi()) + titleRes = MR.strings.recommendation_only_on_wifi + defaultValue = true + + preferences.recommendationHour().changesIn(viewScope) { isVisible = it >= 0 } + + onChange { + viewScope.launchUI { RecommendationJob.setupTask(context) } + true + } + } + + // Right now. + preference { + titleRes = MR.strings.recommendation_rebuild_now + val store = Injekt.get() + fun refreshSummary(progress: BuildProgress?) { + if (progress != null) { + summary = progress.label(context) + return + } + // Off the main thread: the snapshot is a file, and a few hundred entries to parse. + viewScope.launchIO { + val text = store.read()?.let { snapshot -> + context.getString( + MR.strings.recommendation_last_built, + DateUtils.getRelativeTimeSpanString(snapshot.builtAt, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS), + ) + } ?: context.getString(MR.strings.recommendation_never_built) + withUIContext { summary = text } + } + } + RecommendationJob.progressFlow(context).onEach { refreshSummary(it) }.launchIn(viewScope) + onClick { + RecommendationJob.runNow(context, RecommendationJob.MODE_FULL) + refreshSummary(BuildProgress(BuildProgress.Stage.PROFILE)) + } + } + } } override fun onActivityResumed(activity: Activity) { @@ -310,5 +431,9 @@ class SettingsBrowseController : SettingsLegacyController() { private companion object { /** Overnight-heavy: the pass is a burst of searches nobody should be waiting on. */ val MIGRATE_HOURS = listOf(0, 3, 6, 9, 12, 18, 21) + /** Taste moves slowly and a build is a night of requests: no reason to run it daily. */ + val RECOMMENDATION_INTERVALS = listOf(7, 15, 30, 90, 180) + val RECOMMENDATION_TARGETS = listOf(25, 50, 150, 250, 500) + val RECOMMENDATION_RECENT_YEARS = listOf(1, 2, 3, 5, 10) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt index fee24c1ada..40b42a59d2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt @@ -755,6 +755,11 @@ class BrowseController : */ fun setLastUsedSource(item: SourceItem?) { adapter?.removeAllScrollableHeaders() + // Added bottom-up: the recommendations sit right under "last used", above the languages. + presenter.recommendationItem?.let { + adapter?.addScrollableHeader(it) + adapter?.addScrollableHeader(LangItem(SourcePresenter.RECOMMENDATIONS_KEY)) + } if (item != null) { adapter?.addScrollableHeader(item) adapter?.addScrollableHeader(LangItem(SourcePresenter.LAST_USED_KEY)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceHolder.kt index 0c9d8094aa..fef5f13573 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceHolder.kt @@ -9,6 +9,8 @@ import karasu.util.lang.getString import dev.icerock.moko.resources.compose.stringResource import eu.kanade.tachiyomi.databinding.SourceItemBinding import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.RecommendationSource +import eu.kanade.tachiyomi.util.lang.addExperimentalTag import eu.kanade.tachiyomi.source.icon import eu.kanade.tachiyomi.source.includeLangInName import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder @@ -43,8 +45,9 @@ class SourceHolder(view: View, val adapter: SourceAdapter) : val isPinned = item.isPinned ?: underPinnedSection val showLanguage = source.includeLangInName(adapter.enabledLanguages, adapter.extensionManager) val sourceName = if (showLanguage && (underPinnedSection || underLastUsedSection)) source.toString() else source.name - binding.title.text = sourceName + binding.title.text = if (source.id == RecommendationSource.ID) sourceName.addExperimentalTag(itemView.context) else sourceName + binding.sourcePin.isVisible = source.id != RecommendationSource.ID binding.sourcePin.apply { imageTintList = ColorStateList.valueOf( context.getResourceColor( @@ -68,9 +71,17 @@ class SourceHolder(view: View, val adapter: SourceAdapter) : // Set circle letter image. itemView.post { val icon = source.icon() + // The crow is a white silhouette drawn for notifications; tinted with the text colour + // it follows the theme. The tint is cleared for everything else: the view is recycled. + binding.sourceImage.imageTintList = if (item.source.id == RecommendationSource.ID) { + ColorStateList.valueOf(itemView.context.getResourceColor(AR.attr.textColorPrimary)) + } else { + null + } when { icon != null -> binding.sourceImage.setImageDrawable(icon) item.source.id == LocalSource.ID -> binding.sourceImage.setImageResource(R.mipmap.ic_local_source) + item.source.id == RecommendationSource.ID -> binding.sourceImage.setImageResource(R.drawable.ic_karasu) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceItem.kt index 9afbfcd4f9..56f81d947b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceItem.kt @@ -32,7 +32,7 @@ class SourceItem(val source: CatalogueSource, header: LangItem? = null, val isPi } override fun isSwipeable(): Boolean { - return Injekt.get().enableSourceSwipeAction().get() && source.id != LocalSource.ID && header != null && header.code != SourcePresenter.LAST_USED_KEY + return Injekt.get().enableSourceSwipeAction().get() && source.id != LocalSource.ID && header != null && header.code != SourcePresenter.LAST_USED_KEY && header.code != SourcePresenter.RECOMMENDATIONS_KEY } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourcePresenter.kt index b71c4c4c10..189e7c80ab 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourcePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourcePresenter.kt @@ -4,6 +4,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.RecommendationSource import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.util.system.withUIContext import kotlinx.coroutines.CoroutineScope @@ -37,6 +38,12 @@ class SourcePresenter( var sourceItems = emptyList() var lastUsedItem: SourceItem? = null + /** The recommendations window, pinned under "last used" rather than filed under "other". */ + val recommendationItem: SourceItem? + get() = (sourceManager.get(RecommendationSource.ID) as? CatalogueSource) + ?.takeUnless { it.id.toString() in preferences.hiddenSources().get() } + ?.let { SourceItem(it, LangItem(RECOMMENDATIONS_KEY)) } + var lastUsedJob: Job? = null fun onCreate() { @@ -139,6 +146,8 @@ class SourcePresenter( return sourceManager.getCatalogueSources() .filter { it.lang in languages || it.id == LocalSource.ID } + // Shown as its own section above the languages, see [recommendationItem]. + .filterNot { it.id == RecommendationSource.ID } .filterNot { it.id.toString() in hiddenCatalogues } .sortedBy { "(${it.lang}) ${it.name}" } } @@ -146,6 +155,7 @@ class SourcePresenter( companion object { const val PINNED_KEY = "pinned" const val LAST_USED_KEY = "last_used" + const val RECOMMENDATIONS_KEY = "recommendations" private var lastSources: List? = null private var lastUsedItemRem: SourceItem? = null diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt index 58a082b115..6fb087def3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt @@ -1,7 +1,13 @@ package eu.kanade.tachiyomi.ui.source.browse import android.os.Build +import android.app.Activity +import android.R as AR import android.os.Bundle +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.RelativeSizeSpan import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -34,6 +40,15 @@ import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.RecommendationSource +import eu.kanade.tachiyomi.ui.setting.controllers.SettingsBrowseController +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.distinctUntilChanged +import eu.kanade.tachiyomi.data.recommendation.RecommendationJob +import eu.kanade.tachiyomi.data.recommendation.RecommendationJob.Companion.label +import karasu.presentation.recommendation.RecommendationTagsController +import karasu.domain.recommendation.RecommendationFeedback import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.online.HttpSource @@ -51,6 +66,7 @@ import eu.kanade.tachiyomi.util.addOrRemoveToFavorites import eu.kanade.tachiyomi.util.system.connectivityManager import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.e +import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.materialAlertDialog import eu.kanade.tachiyomi.util.system.openInBrowser @@ -78,6 +94,8 @@ import kotlin.math.roundToInt import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import karasu.domain.manga.interactor.GetManga import karasu.domain.source.browse.filter.models.SavedSearch @@ -152,6 +170,10 @@ open class BrowseSourceController(bundle: Bundle) : /** Current filter sheet */ private var filterSheet: SourceFilterSheet? = null private var lastPosition: Int = -1 + private var tagsEdited = false + + /** The recommendation build the adapter's cards came from, to notice when a newer one exists. */ + private var shownBuild: Long? = null // Basically a cache just so the filter sheet is shown faster var savedSearches by mutableStateOf(emptyList()) @@ -192,6 +214,51 @@ open class BrowseSourceController(bundle: Bundle) : override fun onViewCreated(view: View) { super.onViewCreated(view) + if (presenter.source is RecommendationSource) { + // A rebuild started here, from settings, or by the source itself on a first open + // with nothing built: the snack follows its stages, and the list reloads when it + // lands. + var wasRunning = false + RecommendationJob.stateFlow(view.context) + .distinctUntilChanged() + .onEach { state -> + when { + state is RecommendationJob.Companion.RunState.Running -> { + if (!wasRunning) { + // A rebuild wiped the list: the old cards are what it was asked + // to get rid of. A batch being added leaves them where they are. + val window = presenter.source as? RecommendationSource + viewScope.launchIO { + if (window?.currentBuild() == null) { + withUIContext { + adapter?.clear() + showProgressBar() + } + } + } + } + wasRunning = true + val text = state.progress.label(view.context) + snack?.takeIf { it.isShown && it.duration == Snackbar.LENGTH_INDEFINITE }?.setText(text) + ?: run { snack = view.snack(text, Snackbar.LENGTH_INDEFINITE) } + } + wasRunning -> { + wasRunning = false + snack?.dismiss() + if (state is RecommendationJob.Companion.RunState.Failed) { + snack = view.snack(MR.strings.recommendation_refresh_failed, Snackbar.LENGTH_LONG) + } else { + snack = view.snack(MR.strings.recommendation_refreshed) + } + } + } + // Whatever the state, cards from an older build than the one on disk are + // replaced. This is the check that cannot miss: it does not depend on having + // watched the job run. + if (state !is RecommendationJob.Companion.RunState.Running) refreshIfStale() + } + .launchIn(viewScope) + } // Initialize adapter, scroll listener and recycler views adapter = FlexibleAdapter(null, this, false) @@ -376,6 +443,10 @@ open class BrowseSourceController(bundle: Bundle) : val isLocalSource = presenter.source is LocalSource menu.findItem(R.id.action_local_source_help).isVisible = isLocalSource + menu.findItem(R.id.action_recommendation_tags).isVisible = presenter.source is RecommendationSource + menu.findItem(R.id.action_recommendation_refresh).isVisible = presenter.source is RecommendationSource + menu.findItem(R.id.action_recommendation_settings).isVisible = presenter.source is RecommendationSource + menu.findItem(R.id.action_recommendation_rebuild).isVisible = presenter.source is RecommendationSource } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -384,6 +455,17 @@ open class BrowseSourceController(bundle: Bundle) : R.id.action_display_mode -> swapDisplayMode() R.id.action_open_in_web_view -> openInWebView() R.id.action_local_source_help -> openLocalSourceHelpGuide() + // The progress flow above picks it up and says where it is. + // Refresh: one more batch of fifty, or a fresh build when there is nothing queued. + R.id.action_recommendation_refresh -> view?.context?.let { RecommendationJob.runNow(it, RecommendationJob.MODE_CONTINUE) } + R.id.action_recommendation_rebuild -> view?.context?.let { RecommendationJob.runNow(it, RecommendationJob.MODE_FULL) } + R.id.action_recommendation_settings -> router.pushController( + SettingsBrowseController().apply { preferenceKey = RECOMMENDATION_SETTINGS_KEY }.withFadeTransaction(), + ) + R.id.action_recommendation_tags -> { + tagsEdited = true + router.pushController(RecommendationTagsController().withFadeTransaction()) + } R.id.action_source_settings -> openSourceSettings() else -> return super.onOptionsItemSelected(item) } @@ -600,6 +682,12 @@ open class BrowseSourceController(bundle: Bundle) : adapter?.notifyItemChanged(lastPosition, false) lastPosition = -1 } + // Weights changed under the list: refetch. Only after the tags screen, so coming back + // from a manga keeps the scroll position. + if (type == ControllerChangeType.POP_ENTER && tagsEdited) { + tagsEdited = false + presenter.restartPager() + } } /** @@ -626,12 +714,35 @@ open class BrowseSourceController(bundle: Bundle) : * @param page the current page. * @param mangas the list of manga of the page. */ + /** + * The presenter keeps its pages across view recreations, so a list built while this screen + * was covered would come back as it was. The build stamp the cards came from is compared + * with the one on disk, and a newer one restarts the pager, which clears the adapter. + */ + private fun refreshIfStale() { + val window = presenter.source as? RecommendationSource ?: return + viewScope.launchIO { + val current = window.currentBuild() + if (current != null && current != shownBuild) { + withUIContext { presenter.restartPager() } + } + } + } + + override fun onAttach(view: View) { + super.onAttach(view) + refreshIfStale() + } + fun onAddPage(page: Int, mangas: List) { val adapter = adapter ?: return hideProgressBar() if (page == 1) { adapter.clear() resetProgressItem() + (presenter.source as? RecommendationSource)?.let { window -> + viewScope.launchIO { shownBuild = window.currentBuild() } + } } adapter.onLoadMoreComplete(mangas) if (isControllerVisible) { @@ -891,6 +1002,45 @@ open class BrowseSourceController(bundle: Bundle) : val manga = (adapter?.getItem(position) as? BrowseSourceItem?)?.manga ?: return val view = view ?: return val activity = activity ?: return + val window = presenter.source as? RecommendationSource + if (window != null) { + // On the window the long press is a verdict, not only "add to library" — and the one + // place the grade can say why this is here. + val title = SpannableStringBuilder(manga.title) + window.describe(manga.source, manga.url)?.let { why -> + val start = title.length + 1 + title.append("\n").append(why) + title.setSpan(RelativeSizeSpan(0.75f), start, title.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + title.setSpan(ForegroundColorSpan(activity.getResourceColor(AR.attr.textColorSecondary)), start, title.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + activity.materialAlertDialog() + .setTitle(title) + .setItems( + arrayOf( + activity.getString(if (manga.favorite) MR.strings.remove_from_library else MR.strings.add_to_library), + activity.getString(MR.strings.recommendation_not_interested), + activity.getString(MR.strings.recommendation_already_read), + ), + ) { _, which -> + val kind = when (which) { + 1 -> RecommendationFeedback.Kind.NOT_INTERESTED + 2 -> RecommendationFeedback.Kind.ALREADY_READ + else -> null + } + if (kind == null) { + toggleFavorite(manga, position, view, activity) + } else { + window.feedback(manga.source, manga.url, kind) + adapter?.removeItem(position) + } + } + .show() + return + } + toggleFavorite(manga, position, view, activity) + } + + private fun toggleFavorite(manga: Manga, position: Int, view: View, activity: Activity) { viewScope.launchIO { withUIContext { snack?.dismiss() } snack = manga.addOrRemoveToFavorites( @@ -902,6 +1052,8 @@ open class BrowseSourceController(bundle: Bundle) : onMangaAdded = { adapter?.notifyItemChanged(position) snack = view.snack(MR.strings.added_to_library) + // Taking a recommendation is the clearest yes the window ever gets. + (presenter.source as? RecommendationSource)?.feedback(manga.source, manga.url, RecommendationFeedback.Kind.ADDED) }, onMangaMoved = { adapter?.notifyItemChanged(position) }, onMangaDeleted = { presenter.confirmDeletion(manga) }, @@ -918,6 +1070,9 @@ open class BrowseSourceController(bundle: Bundle) : companion object { const val SOURCE_ID_KEY = "sourceId" + /** The preference the settings screen scrolls to and highlights. */ + private const val RECOMMENDATION_SETTINGS_KEY = "recommendation_hour" + const val SEARCH_QUERY_KEY = "searchQuery" const val USE_LATEST_KEY = "useLatest" const val SMART_SEARCH_CONFIG_KEY = "smartSearchConfig" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceHolder.kt index f6ef93f4c7..b695c78532 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceHolder.kt @@ -25,6 +25,9 @@ abstract class BrowseSourceHolder(view: View, adapter: FlexibleAdapter, private val catalogueListType: Preference, private val outlineOnCovers: Preference, + /** A line under the title in list layout; the recommendations put their reason here. */ + val subtitle: String? = null, ) : AbstractFlexibleItem() { @@ -80,6 +82,7 @@ class BrowseSourceItem( payloads: MutableList?, ) { holder.onSetValues(manga) + holder.onSetSubtitle(subtitle) holder.bindChapterCount(manga) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceListHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceListHolder.kt index 86a35a7ee1..aa413576fd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceListHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceListHolder.kt @@ -58,6 +58,11 @@ class BrowseSourceListHolder( setImage(manga) } + override fun onSetSubtitle(subtitle: String?) { + binding.subtitle.text = subtitle + binding.subtitle.isVisible = !subtitle.isNullOrBlank() + } + override fun setImage(manga: Manga) { // Update the cover. isFavorite = manga.favorite diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt index 212b53e455..e82d275af2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt @@ -14,6 +14,11 @@ import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.model.safeMemo +import eu.kanade.tachiyomi.source.RecommendationSource +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull import eu.kanade.tachiyomi.ui.base.presenter.BaseCoroutinePresenter import eu.kanade.tachiyomi.ui.source.filter.CheckboxItem import eu.kanade.tachiyomi.ui.source.filter.CheckboxSectionItem @@ -204,17 +209,19 @@ open class BrowseSourcePresenter( pager.asFlow() .map { (first, second) -> first to second - .map { networkToLocalManga(it, sourceId) } - .filter { !preferences.hideInLibraryItems().get() || !it.favorite } + .map { networkToLocalManga(it, sourceId) to it.safeMemo()[RecommendationSource.BECAUSE_MEMO]?.jsonPrimitive?.contentOrNull } + // The recommendations are library entries by design; hiding them would empty it. + .filter { (manga, _) -> sourceId == RecommendationSource.ID || !preferences.hideInLibraryItems().get() || !manga.favorite } } - .onEach { initializeMangas(it.second) } + .onEach { initializeMangas(it.second.map { (manga, _) -> manga }) } .map { (first, second) -> - first to second.map { + first to second.map { (manga, because) -> BrowseSourceItem( - it, + manga, browseAsList, sourceListType, outlineCovers, + subtitle = because, ) } } @@ -264,7 +271,9 @@ open class BrowseSourcePresenter( * @param sManga the manga from the source. * @return a manga from the database. */ - private suspend fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga { + private suspend fun networkToLocalManga(sManga: SManga, browsedSourceId: Long): Manga { + // A recommendation is a window onto a manga of another source: never a row of its own. + val sourceId = sManga.safeMemo()[RecommendationSource.SOURCE_MEMO]?.jsonPrimitive?.longOrNull ?: browsedSourceId var localManga = getManga.awaitByUrlAndSource(sManga.url, sourceId) if (localManga == null) { val newManga = Manga.create(sManga.url, sManga.title, sourceId) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/globalsearch/GlobalSearchPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/globalsearch/GlobalSearchPresenter.kt index f7fb16c502..662480cd93 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/globalsearch/GlobalSearchPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/globalsearch/GlobalSearchPresenter.kt @@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.RecommendationSource import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.SManga @@ -45,6 +46,10 @@ open class GlobalSearchPresenter( private val initialQuery: String? = "", private val initialExtensionFilter: String? = null, private val sourcesToUse: List? = null, + /** Sources that make no sense to search: for a merge, the ones the series is already on. */ + private val excludedSources: Set = emptySet(), + /** Other names the series goes by, tried on a source in turn when the query found nothing. */ + private val fallbackQueries: List = emptyList(), val sourceManager: SourceManager = Injekt.get(), private val preferences: PreferencesHelper = Injekt.get(), private val coverCache: CoverCache = Injekt.get(), @@ -104,6 +109,8 @@ open class GlobalSearchPresenter( val list = sourceManager.getCatalogueSources() .filter { it.lang in languages } .filterNot { it.id.toString() in hiddenCatalogues } + // The recommendations window is not a catalogue: a hit from it would be filed under its id. + .filterNot { it.id == RecommendationSource.ID } .sortedBy { "(${it.lang}) ${it.name}" } return if (preferences.onlySearchPinned().get()) { @@ -125,7 +132,13 @@ open class GlobalSearchPresenter( private fun List.healthyFirst(): List = sortedBy { readFailures.isSourceFailing(it.id) } - private fun getSourcesToQuery(): List { + private fun getSourcesToQuery(): List = pickSourcesToQuery().filterNot { it.id in excludedSources } + + private companion object { + const val MAX_FALLBACKS = 4 + } + + private fun pickSourcesToQuery(): List { if (sourcesToUse != null) return sourcesToUse.healthyFirst() val filter = extensionFilter val enabledSources = getEnabledSources().healthyFirst() @@ -171,6 +184,21 @@ open class GlobalSearchPresenter( * * @param query query on which to search. */ + /** + * The query, then each fallback name until one finds something. A pt-BR source lists the + * series under the group's own title; a tracker's romaji or English name is what finds it. + */ + private suspend fun searchWithFallbacks(source: CatalogueSource, query: String): MangasPage { + // Every extra name is another request on every source that came back empty. + val names = listOf(query) + fallbackQueries.filter { !it.equals(query, ignoreCase = true) }.take(MAX_FALLBACKS) + var last = MangasPage(emptyList(), false) + for (name in names) { + last = source.getSearchManga(1, name, source.getFilterList()) + if (last.mangas.isNotEmpty()) return last + } + return last + } + fun search(query: String) { // Return if there's nothing to do if (this.query == query) return @@ -196,7 +224,7 @@ open class GlobalSearchPresenter( return@mainLaunch } val mangas = try { - source.getSearchManga(1, query, source.getFilterList()) + searchWithFallbacks(source, query) .also { readFailures.clearSource(source.id) } } catch (error: Exception) { // Remembering this is what makes the ordering above self-correcting: diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt index 6c0ed9c721..fd9b53536c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt @@ -52,6 +52,11 @@ suspend fun syncChaptersWithSource( // is deleted below, so a merged list would delete the other sources' chapters here. val dbChapters = getChapter.awaitAllRaw(manga.id!!, false) + // A source with no dates of its own stamps the moment it was asked on every chapter, so + // every chapter is "0 minutes ago" and every sync sees every date changed. Those stamps are + // dropped here, once, before anything reads them: the chapter list, the library's "recently + // updated", the release calendar and the recommendations all get "unknown" instead of a lie. + val fetchStamped = datesAreTheFetch(rawSourceChapters.map { it.date_upload }) val sourceChapters = rawSourceChapters .distinctBy { it.url } .mapIndexed { i, sChapter -> @@ -60,6 +65,7 @@ suspend fun syncChaptersWithSource( name = with(ChapterSanitizer) { sChapter.name.sanitize(manga.title) } manga_id = manga.id source_order = i + if (fetchStamped) date_upload = 0L } } @@ -254,3 +260,17 @@ internal fun shouldUpdateDbChapter(dbChapter: Chapter, sourceChapter: Chapter): dbChapter.chapter_number != sourceChapter.chapter_number || dbChapter.source_order != sourceChapter.source_order } + +/** + * True when the upload dates are the time of the request rather than of the releases: three or + * more chapters, all within a day of each other, and that day is now. A series really posted + * in a single day exists, but not one whose whole run was posted today. + */ +internal fun datesAreTheFetch(dates: List, now: Long = System.currentTimeMillis()): Boolean { + val real = dates.filter { it > 0 } + if (real.size < 3) return false + val span = real.max() - real.min() + return span < FETCH_STAMP_WINDOW_MS && now - real.max() < FETCH_STAMP_WINDOW_MS +} + +private const val FETCH_STAMP_WINDOW_MS = 24L * 60 * 60 * 1000 diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt index 38ef97e246..2c02b7837e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt @@ -194,6 +194,19 @@ fun String.addBetaTag(context: Context, useSuperScript: Boolean = true): Spanned } } +/** Like [addBetaTag], for what is further from done than beta. */ +fun String.addExperimentalTag(context: Context, useSuperScript: Boolean = true): Spanned { + val text = context.getString(MR.strings.experimental) + val color = context.getResourceColor(R.attr.colorSecondary) + return buildSpannedString { + append(this@addExperimentalTag) + val tag: SpannableStringBuilder.() -> Unit = { + bold { scale(0.75f) { color(color) { append(text) } } } + } + if (useSuperScript) superscript(tag) else tag() + } +} + fun String.toNormalized(): String = replace("’", "'") fun String.getUrlWithoutDomain(): String { diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt index f99d65a77f..cb10b0363f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt @@ -20,6 +20,7 @@ object LocaleHelper { "", "other" -> context.getString(MR.strings.other) SourcePresenter.LAST_USED_KEY -> context.getString(MR.strings.last_used) SourcePresenter.PINNED_KEY -> context.getString(MR.strings.pinned) + SourcePresenter.RECOMMENDATIONS_KEY -> context.getString(MR.strings.recommendation_section) "all" -> context.getString(MR.strings.all) else -> getLocalizedDisplayName(lang) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt index 57686b0350..ba1f010f15 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt @@ -73,6 +73,12 @@ enum class Themes(@StyleRes val styleRes: Int, val nightMode: Int, val nameRes: R.style.Theme_Tachiyomi_Doki, AppCompatDelegate.MODE_NIGHT_YES, MR.strings.doki, + ), + DRACULA( + R.style.Theme_Tachiyomi_Dracula, + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, + MR.strings.alucard, + MR.strings.dracula, ) ; diff --git a/app/src/main/java/karasu/core/di/DomainModule.kt b/app/src/main/java/karasu/core/di/DomainModule.kt index 3fe8a2af88..df46bbbef6 100644 --- a/app/src/main/java/karasu/core/di/DomainModule.kt +++ b/app/src/main/java/karasu/core/di/DomainModule.kt @@ -2,6 +2,7 @@ package karasu.core.di import eu.kanade.tachiyomi.source.MergedSourceFallback import eu.kanade.tachiyomi.util.chapter.MergedSourceSync +import android.app.Application import org.koin.dsl.module import karasu.data.category.CategoryRepositoryImpl import karasu.data.chapter.ChapterRepositoryImpl @@ -52,6 +53,16 @@ import karasu.domain.manga.failures.ReadFailures import karasu.domain.manga.failures.interactor.GetBrokenSources import karasu.domain.manga.failures.interactor.UpdateFailures import karasu.domain.manga.interactor.GetLibraryManga +import karasu.domain.recommendation.BuildRecommendations +import karasu.domain.recommendation.DiscardRecommendations +import karasu.domain.recommendation.GetTasteProfile +import karasu.domain.recommendation.RecommendationFeedbackStore +import karasu.domain.recommendation.RecommendationStore +import karasu.domain.recommendation.TrackerExtrasStore +import karasu.domain.recommendation.EnrichFromTrackers +import karasu.domain.recommendation.FindMergeCandidates +import karasu.domain.recommendation.MergeSuggestionsStore +import karasu.domain.migration.MangaAliases import karasu.domain.manga.interval.FetchInterval import karasu.domain.manga.interval.GetReleaseSchedule import karasu.domain.manga.interval.RecalculateReleaseEstimates @@ -110,7 +121,17 @@ fun domainModule() = module { single { MangaUpdateFailureRepositoryImpl(get()) } factory { UpdateFailures(get()) } factory { FetchInterval(get()) } - factory { GetReleaseSchedule(get(), get()) } + factory { GetReleaseSchedule(get(), get(), get()) } + factory { GetTasteProfile(get(), get(), get(), get(), get(), get(), get(), get()) } + single { RecommendationFeedbackStore(get()) } + factory { BuildRecommendations(get(), get(), get(), get(), get(), get()) } + single { RecommendationStore(get()) } + factory { DiscardRecommendations(get(), get(), get()) } + single { TrackerExtrasStore(get()) } + factory { EnrichFromTrackers(get(), get(), get(), get(), get()) } + single { MergeSuggestionsStore(get()) } + factory { MangaAliases(get(), get(), get()) } + factory { FindMergeCandidates(get(), get(), get(), get(), get(), get(), get()) } factory { RecalculateReleaseEstimates(get(), get(), get()) } // In-memory, so every reader and the broken-sources screen have to be looking at the same one. single { ReadFailures() } diff --git a/app/src/main/java/karasu/data/chapter/ChapterRepositoryImpl.kt b/app/src/main/java/karasu/data/chapter/ChapterRepositoryImpl.kt index 860af41f1b..ce67a798aa 100644 --- a/app/src/main/java/karasu/data/chapter/ChapterRepositoryImpl.kt +++ b/app/src/main/java/karasu/data/chapter/ChapterRepositoryImpl.kt @@ -64,6 +64,9 @@ class ChapterRepositoryImpl(private val handler: DatabaseHandler) : ChapterRepos override suspend fun getScanlatorsByChapter(mangaId: Long): List = handler.awaitList { chaptersQueries.getScanlatorsByMangaId(mangaId) { it.orEmpty() } } + override suspend fun getAllScanlators(): List = + handler.awaitList { chaptersQueries.getAllScanlators() } + override fun getScanlatorsByChapterAsFlow(mangaId: Long): Flow> = handler.subscribeToList { chaptersQueries.getScanlatorsByMangaId(mangaId) { it.orEmpty() } } diff --git a/app/src/main/java/karasu/domain/chapter/ChapterRepository.kt b/app/src/main/java/karasu/domain/chapter/ChapterRepository.kt index 89cdb9a74f..d55c13117b 100644 --- a/app/src/main/java/karasu/domain/chapter/ChapterRepository.kt +++ b/app/src/main/java/karasu/domain/chapter/ChapterRepository.kt @@ -27,6 +27,7 @@ interface ChapterRepository { suspend fun getRecents(filterScanlators: Boolean, search: String = "", limit: Long = 25L, offset: Long = 0L): List suspend fun getScanlatorsByChapter(mangaId: Long): List + suspend fun getAllScanlators(): List fun getScanlatorsByChapterAsFlow(mangaId: Long): Flow> suspend fun delete(chapter: Chapter): Boolean diff --git a/app/src/main/java/karasu/domain/chapter/interactor/GetChapter.kt b/app/src/main/java/karasu/domain/chapter/interactor/GetChapter.kt index 14e543be83..8fd0ffa699 100644 --- a/app/src/main/java/karasu/domain/chapter/interactor/GetChapter.kt +++ b/app/src/main/java/karasu/domain/chapter/interactor/GetChapter.kt @@ -28,6 +28,13 @@ class GetChapter( /** How many chapters are stored for [mangaId]. Never merged, and never reads the rows. */ suspend fun awaitCountRaw(mangaId: Long) = chapterRepository.countChapters(mangaId) + /** Every scanlator name in the library, each group of a joint release on its own. */ + suspend fun awaitAllScanlators(): Set = chapterRepository.getAllScanlators() + .flatMap { it.split('&', ',', '/', '+', '|') } + .map { it.trim().lowercase() } + .filter { it.isNotBlank() } + .toSet() + suspend fun awaitAll(mangaId: Long, filterScanlators: Boolean): List { val own = chapterRepository.getChapters(mangaId, filterScanlators) if (!mergedSources.hasMerges(mangaId)) return own diff --git a/app/src/main/java/karasu/domain/manga/interval/FetchInterval.kt b/app/src/main/java/karasu/domain/manga/interval/FetchInterval.kt index 4ff20dafe4..aed04c2e38 100644 --- a/app/src/main/java/karasu/domain/manga/interval/FetchInterval.kt +++ b/app/src/main/java/karasu/domain/manga/interval/FetchInterval.kt @@ -3,6 +3,9 @@ package karasu.domain.manga.interval import eu.kanade.tachiyomi.data.database.models.Chapter import karasu.data.DatabaseHandler import kotlin.math.roundToInt +import java.time.ZoneOffset +import java.time.ZoneId +import java.time.Instant /** * Stores when each manga is expected to release, and therefore when to ask about it. @@ -135,12 +138,27 @@ class FetchInterval( * row that carries that number. Unnumbered chapters have nothing to collapse against and are * kept as they are. */ -fun releaseDates(chapters: List): Pair, List> { +fun releaseDates(chapters: List, zone: ZoneId = ZoneId.systemDefault()): Pair, List> { val (numbered, unnumbered) = chapters.partition { it.isRecognizedNumber } val perNumber = numbered.groupBy { (it.chapter_number * 1000f).roundToInt() }.values - val uploads = perNumber.map { group -> group.map { it.date_upload }.filter { it > 0 }.minOrNull() ?: 0L } + - unnumbered.map { it.date_upload } + val uploads = (perNumber.map { group -> group.map { it.date_upload }.filter { it > 0 }.minOrNull() ?: 0L } + + unnumbered.map { it.date_upload }).map { asLocalRelease(it, zone) } val fetches = perNumber.map { group -> group.minOf { it.date_fetch } } + unnumbered.map { it.date_fetch } return uploads to fetches } + +/** + * A stamp at exactly midnight UTC is a date the source knew, not a moment: "Monday", stored the + * only way a date fits in a millisecond field. Read as an instant it is Sunday evening anywhere + * west of Greenwich, and the whole calendar slides a day early. So it becomes noon, local, of + * that date — the middle of the day it named. A stamp with a time of day is left exactly as it + * is: that one really happened then. + */ +fun asLocalRelease(stamp: Long, zone: ZoneId = ZoneId.systemDefault()): Long { + if (stamp <= 0 || stamp % DAY_MS != 0L) return stamp + val date = Instant.ofEpochMilli(stamp).atZone(ZoneOffset.UTC).toLocalDate() + return date.atTime(12, 0).atZone(zone).toInstant().toEpochMilli() +} + +private const val DAY_MS = 24L * 60 * 60 * 1000 diff --git a/app/src/main/java/karasu/domain/manga/interval/GetReleaseSchedule.kt b/app/src/main/java/karasu/domain/manga/interval/GetReleaseSchedule.kt index 2f6ddb36b7..63ef522110 100644 --- a/app/src/main/java/karasu/domain/manga/interval/GetReleaseSchedule.kt +++ b/app/src/main/java/karasu/domain/manga/interval/GetReleaseSchedule.kt @@ -4,7 +4,9 @@ import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.source.model.SManga import java.time.Instant import java.time.LocalDate +import java.time.YearMonth import java.time.ZoneId +import karasu.domain.recommendation.TrackerExtrasStore import karasu.domain.manga.interactor.GetLibraryManga /** @@ -17,16 +19,27 @@ import karasu.domain.manga.interactor.GetLibraryManga * upload dates and has not been watched long enough to learn the rhythm from fetch times. These * are shown rather than hidden, because a calendar missing half the library with no explanation * reads as broken. + * @param arrived entries that got a chapter today. The moment one lands its estimate moves on + * to the next cycle and it would vanish from today — exactly when the reader wants to see it. + * @param onHiatus entries the source, or a tracker, says are paused. Not "stalled": that would + * read as "the source seems to have stopped", when what happened is the author said so. */ data class ReleaseSchedule( val upcoming: List, val stalled: List, val unknown: List, + val arrived: List = emptyList(), + val onHiatus: List = emptyList(), ) +/** + * @param guessed the estimate is not the entry's own rhythm but its source's typical one, + * stood in until the entry has released enough times to speak for itself. + */ data class ScheduledRelease( val manga: Manga, val estimate: ReleaseEstimate, + val guessed: Boolean = false, ) /** One day of the calendar. Days with nothing expected are kept so the week reads as a week. */ @@ -79,40 +92,102 @@ fun ReleaseSchedule.calendar( ) } +/** + * One month, every day, for the grid view. Days before today are empty: what was expected then + * either arrived or is on today already. + */ +data class ReleaseMonth(val month: YearMonth, val days: List) + +fun ReleaseSchedule.month( + month: YearMonth, + now: Long = System.currentTimeMillis(), + zone: ZoneId = ZoneId.systemDefault(), + grace: Long = ReleaseEstimate.MISS_GRACE, +): ReleaseMonth { + val today = Instant.ofEpochMilli(now).atZone(zone).toLocalDate() + val byDate = upcoming.groupBy { release -> + val date = Instant.ofEpochMilli(release.estimate.expectedRelease(now, grace)).atZone(zone).toLocalDate() + if (date.isBefore(today)) today else date + } + return ReleaseMonth( + month = month, + days = (1..month.lengthOfMonth()).map { day -> + val date = month.atDay(day) + ReleaseDay(date, byDate[date].orEmpty()) + }, + ) +} + /** Statuses under which no further chapter is coming. Hiatus is not one: it may end. */ private val FINISHED = setOf(SManga.COMPLETED, SManga.CANCELLED, SManga.LICENSED) class GetReleaseSchedule( private val getLibraryManga: GetLibraryManga, private val fetchInterval: FetchInterval, + private val extras: TrackerExtrasStore, ) { /** * @param categories which categories to include. Empty means the whole library, matching how * the same selection is read everywhere else. + * @param onlyCaughtUp leave out entries with more than [CAUGHT_UP_MAX_UNREAD] unread: a new + * chapter on a pile of thirty is not an event the reader is waiting for. */ suspend fun await( categories: Set = emptySet(), + onlyCaughtUp: Boolean = false, now: Long = System.currentTimeMillis(), + zone: ZoneId = ZoneId.systemDefault(), ): ReleaseSchedule { val estimates = fetchInterval.awaitAll() - val library = getLibraryManga.await() + val entries = getLibraryManga.await() .filter { categories.isEmpty() || it.category in categories } .distinctBy { it.manga.id } - .map { it.manga } + .filter { !onlyCaughtUp || it.unread <= CAUGHT_UP_MAX_UNREAD } // Nothing is expected of a finished series, so it belongs on no date and in no - // bucket: left in, every completed entry would sit under "stalled" forever. - .filter { it.status !in FINISHED } + // bucket: left in, every completed entry would sit under "stalled" forever. A tracker + // that says finished outranks a source that never updated its status. + .filter { effectiveStatus(it.manga) !in FINISHED } + + // The typical gap on each source, for entries too new to have one of their own. + val sourceInterval = entries + .mapNotNull { entry -> entry.manga.id?.let { estimates[it] }?.let { entry.manga.source to it.interval } } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, gaps) -> gaps.sorted()[gaps.size / 2] } + + val startOfToday = Instant.ofEpochMilli(now).atZone(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli() val upcoming = mutableListOf() val stalled = mutableListOf() val unknown = mutableListOf() + val arrived = mutableListOf() + val onHiatus = mutableListOf() - library.forEach { manga -> + entries.forEach { entry -> + val manga = entry.manga + // When the app first saw the chapter, not what the source stamped on it: a source + // with no dates stamps "now" on everything, every fetch, and would "arrive" daily. + // Added today is not "a chapter arrived today": the whole backlog came in at once. + // The entry still learns its rhythm from every chapter that arrives from now on. + if (entry.lastFetch >= startOfToday && manga.date_added < startOfToday) arrived.add(manga) + if (effectiveStatus(manga) == SManga.ON_HIATUS) { + onHiatus.add(manga) + return@forEach + } val estimate = manga.id?.let { estimates[it] } when { - estimate == null -> unknown.add(manga) - estimate.isStalled(now) -> stalled.add(ScheduledRelease(manga, estimate)) - else -> upcoming.add(ScheduledRelease(manga, estimate)) + estimate != null && estimate.isStalled(now) -> stalled.add(ScheduledRelease(manga, estimate)) + estimate != null -> upcoming.add(ScheduledRelease(manga, estimate)) + else -> { + val typical = sourceInterval[manga.source] + val last = entry.lastFetch.takeIf { it > 0 } + if (typical != null && last != null) { + // Wide on purpose: half a cycle either way says "probably weekly", no more. + val guess = ReleaseEstimate(nextRelease = last + typical, interval = typical, spread = typical / 2) + if (guess.isStalled(now)) unknown.add(manga) else upcoming.add(ScheduledRelease(manga, guess, guessed = true)) + } else { + unknown.add(manga) + } + } } } @@ -120,6 +195,16 @@ class GetReleaseSchedule( upcoming = upcoming.sortedBy { it.estimate.nextRelease }, stalled = stalled.sortedByDescending { it.estimate.nextRelease }, unknown = unknown.sortedBy { it.title }, + arrived = arrived.sortedBy { it.title }, + onHiatus = onHiatus.sortedBy { it.title }, ) } + + /** What the trackers say when they say something; otherwise what the source says. */ + private fun effectiveStatus(manga: Manga): Int = extras.get(manga.id)?.status ?: manga.status + + companion object { + /** "Caught up" for the calendar's purposes. A couple unread is a backlog of an evening. */ + const val CAUGHT_UP_MAX_UNREAD = 2 + } } diff --git a/app/src/main/java/karasu/domain/manga/interval/ReleaseEstimate.kt b/app/src/main/java/karasu/domain/manga/interval/ReleaseEstimate.kt index a5a850eee5..c600499b4e 100644 --- a/app/src/main/java/karasu/domain/manga/interval/ReleaseEstimate.kt +++ b/app/src/main/java/karasu/domain/manga/interval/ReleaseEstimate.kt @@ -1,6 +1,8 @@ package karasu.domain.manga.interval import kotlin.math.abs +import java.time.ZoneId +import java.time.Instant /** * What the app has worked out about when a manga releases. @@ -78,6 +80,14 @@ data class ReleaseEstimate( fun pollInterval(): Long = (checkWindow / CHECKS_PER_WINDOW).coerceIn(MIN_POLL, days(1)) companion object { + /** + * Bumped whenever the arithmetic here or in [releaseDates] changes. Stored estimates + * were produced by the old arithmetic and are only rewritten as chapters arrive, so on + * the first launch of a build with a new number the app recalculates them all at once + * rather than being quietly wrong for weeks. + */ + const val ESTIMATOR_VERSION = 2 + /** Never poll a manga faster than this, however sure the estimate is. */ val MIN_POLL = hours(2) @@ -117,6 +127,13 @@ data class ReleaseEstimate( /** Missed windows before a series counts as stalled rather than merely late. */ private const val STALL_CYCLES = 3 + /** An interval in this range is "weekly" for the purpose of holding a weekday. */ + private val WEEKLY_MIN = days(6) + private val WEEKLY_MAX = days(8) + + /** This share of releases on one weekday is a weekday the series keeps. */ + private const val WEEKDAY_AGREEMENT = 0.7 + /** * How long a missed release is still read as "any moment now" before the date rolls on. * The default only applies where there is no user preference to hand — the calendar and @@ -202,12 +219,34 @@ data class ReleaseEstimate( }.coerceAtMost(interval) return ReleaseEstimate( - nextRelease = events.first() + interval, + nextRelease = anchoredNext(events, interval), interval = interval, spread = spread, ) } + /** + * The next release, and for a weekly series that keeps its weekday, the next of *that* + * weekday rather than "last release plus seven days". + * + * The difference is one late chapter: a Monday series that posted Wednesday would + * otherwise expect next Wednesday, and slide a little further every time the source + * slips. Anchored, it expects Monday again. The earliest Monday that is at least half a + * cycle out, so a chapter that came a day *early* does not make tomorrow the next one. + */ + private fun anchoredNext(events: List, interval: Long, zone: ZoneId = ZoneId.systemDefault()): Long { + val plain = events.first() + interval + if (interval !in WEEKLY_MIN..WEEKLY_MAX || events.size < 3) return plain + val days = events.map { Instant.ofEpochMilli(it).atZone(zone) } + val weekday = days.groupingBy { it.dayOfWeek }.eachCount().maxByOrNull { it.value } ?: return plain + if (weekday.value < events.size * WEEKDAY_AGREEMENT) return plain + var candidate = Instant.ofEpochMilli(events.first() + interval / 2).atZone(zone) + while (candidate.dayOfWeek != weekday.key) candidate = candidate.plusDays(1) + // Same time of day the series actually posts, so the check window still fits around it. + val typical = days.first() + return candidate.withHour(typical.hour).withMinute(typical.minute).toInstant().toEpochMilli() + } + /** Newest first, dropping anything that lands inside the previous event's window. */ private fun collapse(datesNewestFirst: List): List { val kept = mutableListOf() diff --git a/app/src/main/java/karasu/domain/migration/MangaAliases.kt b/app/src/main/java/karasu/domain/migration/MangaAliases.kt index beb2d4c444..058e1e35d8 100644 --- a/app/src/main/java/karasu/domain/migration/MangaAliases.kt +++ b/app/src/main/java/karasu/domain/migration/MangaAliases.kt @@ -7,7 +7,10 @@ import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.parseAs +import karasu.domain.recommendation.TrackerExtrasStore import karasu.domain.track.interactor.GetTrack +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.buildJsonObject @@ -29,6 +32,7 @@ import okhttp3.RequestBody.Companion.toRequestBody class MangaAliases( private val getTrack: GetTrack, private val network: NetworkHelper, + private val extras: TrackerExtrasStore = Injekt.get(), ) { /** @@ -41,7 +45,10 @@ class MangaAliases( val tracked = runCatching { getTrack.awaitAllByMangaId(manga.id) } .getOrDefault(emptyList()) .map { it.title } - return (listOf(manga.title) + tracked).clean() + // Then every other name the bound trackers listed, fetched overnight: the scanlation + // group's own Portuguese title is exactly the kind of name only they know. + val known = extras.get(manga.id)?.titles.orEmpty() + return (listOf(manga.title) + tracked + known).clean() } /** diff --git a/app/src/main/java/karasu/domain/recommendation/BuildRecommendations.kt b/app/src/main/java/karasu/domain/recommendation/BuildRecommendations.kt new file mode 100644 index 0000000000..ce449becd9 --- /dev/null +++ b/app/src/main/java/karasu/domain/recommendation/BuildRecommendations.kt @@ -0,0 +1,451 @@ +package karasu.domain.recommendation + +import co.touchlab.kermit.Logger +import eu.kanade.tachiyomi.data.database.models.LibraryManga +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.RecommendationSource +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.SManga +import karasu.domain.chapter.interactor.GetChapter +import karasu.domain.manga.interactor.GetLibraryManga +import karasu.util.normalizedLevenshteinSimilarity +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.async +import okhttp3.RequestBody.Companion.toRequestBody +import kotlinx.serialization.json.putJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonNull +import eu.kanade.tachiyomi.network.parseAs +import eu.kanade.tachiyomi.network.jsonMime +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.POST +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withTimeoutOrNull + +/** + * The whole pipeline, from library to ranked list. Run by the nightly job and by the window when + * it has nothing to show. + * + * Candidates come from the enabled sources, searched with their own genre filter set to one of + * the reader's top tags. Reading a candidate's tags would cost one request per candidate; sending + * the tag in the search costs one request per source per tag, and every hit is known to carry + * that tag. That is only a preliminary rank, though: a hit knows one tag of itself, so page one + * of "Romance" is thirty entries tied at the same score. The shortlist that survives it gets its + * details and chapters read — a bounded number of requests — and is ranked again on everything + * it is. What was not read is not shown: a card with no chapter count is a card that lies. + */ +class BuildRecommendations( + private val getTasteProfile: GetTasteProfile, + private val getLibraryManga: GetLibraryManga, + private val getChapter: GetChapter, + private val sourceManager: SourceManager, + private val preferences: PreferencesHelper, + private val network: NetworkHelper, +) { + /** + * Gathers every candidate — the tag searches across every enabled source, plus the trackers' + * suggestions — and queues them best first. Nothing is read yet: that is [detailBatch]'s job, + * a batch at a time, so a target of five hundred can be met over several runs. + */ + suspend fun search( + now: Long = System.currentTimeMillis(), + onProgress: suspend (BuildProgress) -> Unit = {}, + ): RecommendationSnapshot { + onProgress(BuildProgress(BuildProgress.Stage.PROFILE)) + val profile = getTasteProfile.await() + val library = getLibraryManga.await().distinctBy { it.manga.id } + val known = library.map { it.manga.title.key() }.toSet() + + val fromLibrary = library.filter { it.read == 0 }.map { it.toRecommended() } + val hits = searchSources(profile, onProgress) + // Whatever the library already has, however another source spells it, is not a discovery. + .filter { it.title.key() !in known } + val suggested = suggestions(profile, library, onProgress).filter { it.title.key() !in known } + val suggestedKeys = suggested.map { it.title.key() }.toSet() + + // AniList's catalogue first — the only candidates that arrive already rated by people — + // then the trackers' "like this", then the hits by preliminary rank. Copies of one series + // on several sources stay together, so a batch reads all of them and the one with the + // most chapters can be kept. + val preliminary = profile.rank(hits.filter { it.title.key() !in suggestedKeys }) { it.tags } + .map { it.item.copy(score = it.score, because = it.because) } + val (fromCatalog, fromTrackers) = suggested.partition { it.fromCatalog } + val queue = fromCatalog + fromTrackers + preliminary.groupBy { it.title.key() }.values.flatMap { it.take(COPIES) } + + return RecommendationSnapshot( + builtAt = now, + profileTags = profile.tags, + items = rank(fromLibrary, profile, emptyMap(), now), + librarySize = library.size, + pending = queue, + ) + } + + /** + * Reads the next [batch] series off the queue and folds them into the window: details and + * chapters, one copy kept per series, ranked with everything already there. Small on + * purpose, with the caller pausing between batches, so a long build is a background hum + * rather than a burst that stalls the app. + */ + suspend fun detailBatch( + snapshot: RecommendationSnapshot, + batch: Int = BATCH, + now: Long = System.currentTimeMillis(), + onProgress: suspend (BuildProgress) -> Unit = {}, + ): RecommendationSnapshot { + if (snapshot.pending.isEmpty()) return snapshot + val profile = getTasteProfile.await() + // A batch is [batch] distinct series, copies included. + val keys = snapshot.pending.map { it.title.key() }.distinct().take(batch).toSet() + val (take, rest) = snapshot.pending.partition { it.title.key() in keys } + + val fetched = fetchDetails(take, onProgress) + // Only what was actually read makes the window. An entry whose details failed has no + // chapter count and no real tags: shown, it is the "0 chapters" card the reader opens + // and finds empty. + .filter { it.fetched } + .groupBy { it.title.key() } + .values + // The copy with the most chapters wins, but a tracker naming the series, or two of + // them agreeing, is about the series and travels with it. + .map { same -> + same.maxBy { it.chapters ?: -1 }.copy( + via = same.firstNotNullOfOrNull { it.via }, + votes = same.maxOf { it.votes }, + foundByTag = same.any { it.foundByTag }, + tags = same.flatMap { it.tags }.distinctBy { it.lowercase() }, + ) + } + + onProgress(BuildProgress(BuildProgress.Stage.RANKING)) + val fame = popularityOf(fetched) + val merged = snapshot.items + fetched.map { item -> + fame[item.title.key()]?.let { (pop, avg) -> item.copy(popularity = pop, averageScore = avg) } ?: item + } + return snapshot.copy( + builtAt = now, + items = rank(merged.distinctBy { it.sourceId to it.url }, profile, emptyMap(), now), + pending = rest, + ) + } + + /** The window's order: everything that is shown, scored fresh against [profile]. */ + private fun rank(items: List, profile: TasteProfile, fame: Map>, now: Long): List = + profile.rank(items) { it.tags } + .map { it.item.copy(score = it.score, because = it.because) } + // Nothing to read yet is nothing to recommend, whatever it is tagged with; nor is + // something its source stopped updating long ago for its size. + .filter { it.fromLibrary || (it.chapters != 0 && !it.isOneShot && !it.isStale(now)) } + .map { item -> fame[item.title.key()]?.let { (pop, avg) -> item.copy(popularity = pop, averageScore = avg) } ?: item } + .map { it.withBonuses(now) } + // Recommended on tags the library barely backs: the surprises, kept apart. + .map { item -> item.copy(explore = item.because.isNotEmpty() && item.because.all { (profile.support[it] ?: 0) <= Recommended.EXPLORE_SUPPORT }) } + .sortedByDescending { it.score } + .withExploreSlots() + + /** + * AniList's popularity and mean score for the shortlist, by title, [POPULARITY_BATCH] titles + * per request through aliased fields. Only a close title match counts: a wrong series' fame + * is worse than none. + */ + private suspend fun popularityOf(items: List): Map> { + val titles = items.map { it.title }.distinctBy { it.key() } + val found = mutableMapOf>() + titles.chunked(POPULARITY_BATCH).forEach { batch -> + runCatching { + val fields = batch.indices.joinToString("\n") { i -> + "m$i: Media(search: \$q$i, type: MANGA, format_not_in: [NOVEL]) { title { romaji english } popularity averageScore }" + } + val query = "query(" + batch.indices.joinToString(", ") { "\$q$it: String" } + ") { $fields }" + val payload = buildJsonObject { + put("query", query) + putJsonObject("variables") { batch.forEachIndexed { i, title -> put("q$i", title) } } + } + val data = network.client.newCall(POST(ANILIST, body = payload.toString().toRequestBody(jsonMime))) + .awaitSuccess() + .parseAs()["data"]?.jsonObject ?: return@runCatching + batch.forEachIndexed { i, title -> + val media = data["m$i"]?.takeIf { it !is JsonNull }?.jsonObject ?: return@forEachIndexed + val names = media["title"]?.jsonObject + val close = listOfNotNull(names?.get("romaji"), names?.get("english")) + .mapNotNull { it.jsonPrimitive.contentOrNull } + .any { normalizedLevenshteinSimilarity(it.key(), title.key()) >= POPULARITY_MATCH } + if (!close) return@forEachIndexed + val pop = media["popularity"]?.jsonPrimitive?.intOrNull ?: return@forEachIndexed + found[title.key()] = pop to media["averageScore"]?.jsonPrimitive?.intOrNull + } + }.onFailure { Logger.w(it) { "AniList popularity batch failed" } } + } + return found + } + + /** The catalogues and the trackers' "like this", located in the sources the reader reads from. */ + private suspend fun suggestions( + profile: TasteProfile, + library: List, + onProgress: suspend (BuildProgress) -> Unit, + ): List { + // Every enabled source the library actually uses, most used first: the lookup stops at + // the first that has the series, so the order is what keeps the cost down, and the + // breadth is what lets a big target be met. + val enabled = enabledSources().associateBy { it.id } + val mostRead = library.groupingBy { it.manga.source }.eachCount().entries + .sortedByDescending { it.value } + .mapNotNull { enabled[it.key] } + val languages = preferences.enabledLanguages().get().toList() + val external = ExternalSuggestions(network, languages) + val similar = external.await(profile.seeds.map { it.title }, mostRead) { done, total -> + onProgress(BuildProgress(BuildProgress.Stage.TRACKERS, done, total)) + } + val catalog = runCatching { external.catalog(profile, countryFor(profile), preferences.recommendationRecentYears().get()) } + .onFailure { Logger.w(it) { "AniList catalogue pass failed" } } + .getOrDefault(emptyList()) + val located = external.locate(catalog, mostRead) { done, total -> + onProgress(BuildProgress(BuildProgress.Stage.TRACKERS, done, total)) + } + return (located + similar) + .map { + Recommended( + sourceId = it.sourceId, + url = it.url, + title = it.title, + thumbnail = it.thumbnail, + tags = it.suggestion.genres, + score = 0f, + because = emptyList(), + via = it.suggestion.seed.takeUnless { seed -> it.suggestion.fromCatalog }, + votes = it.suggestion.votes, + popularity = it.suggestion.popularity, + averageScore = it.suggestion.averageScore, + fromCatalog = it.suggestion.fromCatalog, + ) + } + .distinctBy { it.title.key() } + } + + /** + * One search per enabled source per tag the reader is at least [TAG_MIN_WEIGHT] into, sources + * in parallel, tags in series per source. Every tag, not a top few: the point of a periodic + * build is that it can afford to be thorough. Deeper where the taste is stronger + * ([pagesFor]): page one of a tag at 15%, pages one to three of a tag at 100%. + */ + private suspend fun searchSources(profile: TasteProfile, onProgress: suspend (BuildProgress) -> Unit): List { + val liked = profile.tags.filterValues { it >= TAG_MIN_WEIGHT }.entries.take(TAG_CAP).associate { it.key to it.value } + if (liked.isEmpty()) return emptyList() + val sources = enabledSources() + val searched = AtomicInteger() + onProgress(BuildProgress(BuildProgress.Stage.SEARCHING, 0, sources.size)) + val hits = coroutineScope { + sources.map { source -> + async { + runCatching { searchSource(source, liked) }.getOrDefault(emptyList()) + .also { onProgress(BuildProgress(BuildProgress.Stage.SEARCHING, searched.incrementAndGet(), sources.size)) } + } + }.awaitAll() + }.flatten() + + // The same manga found under two tags is one candidate carrying both. + return hits.groupBy { (hit, _) -> hit.sourceId to hit.url } + .values + .map { found -> found.first().first.copy(tags = found.map { (_, tag) -> tag }.distinct()) } + } + + /** Deeper where the taste is stronger. */ + private fun pagesFor(weight: Float): Int = when { + weight >= 0.8f -> 3 + weight >= 0.5f -> 2 + else -> 1 + } + + private suspend fun searchSource(source: CatalogueSource, liked: Map): List> { + val out = mutableListOf>() + for ((tag, weight) in liked) { + for (page in 1..pagesFor(weight)) { + val filters = source.getFilterList() + if (!filters.select(tag)) break + val result = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { + runCatching { source.getSearchManga(page, "", filters) } + .onFailure { Logger.w(it) { "Recommendation search failed on ${source.name} for $tag" } } + .getOrNull() + } ?: break + result.mangas.forEach { + out += Recommended(source.id, it.url, it.title, it.thumbnail_url, listOf(tag), 0f, emptyList(), foundByTag = true) to tag + } + if (!result.hasNextPage) break + } + } + return out + } + + /** + * Where the reader's reading comes from, as AniList spells it, when it is lopsided enough to + * say: a library that is mostly manhwa wants Korean results. Balanced tastes get no filter. + */ + private fun countryFor(profile: TasteProfile): String? { + val formats = profile.formats.entries.sortedByDescending { it.value } + val top = formats.firstOrNull() ?: return null + val second = formats.getOrNull(1)?.value ?: 0f + if (top.value < second * COUNTRY_DOMINANCE) return null + return when (top.key.lowercase()) { + "manhwa", "webtoon" -> "KR" + "manhua" -> "CN" + "manga" -> "JP" + else -> null + } + } + + private fun enabledSources(): List { + val languages = preferences.enabledLanguages().get() + val hidden = preferences.hiddenSources().get() + return sourceManager.getCatalogueSources().filter { + it.lang in languages && it.id.toString() !in hidden && it.id != LocalSource.ID && it.id != RecommendationSource.ID + } + } + + /** + * Reads details and chapters for a batch: full tags to rank on, chapter count and dates to + * tell a new release from an old series. Sources in parallel, one entry at a time within a + * source. An entry that fails stays as it was, unfetched, and is dropped by the caller. + */ + private suspend fun fetchDetails( + batch: List, + onProgress: suspend (BuildProgress) -> Unit, + ): List = coroutineScope { + val detailed = AtomicInteger() + onProgress(BuildProgress(BuildProgress.Stage.DETAILS, 0, batch.size)) + batch.groupBy { it.sourceId }.map { (sourceId, entries) -> + async { + val source = sourceManager.get(sourceId) as? CatalogueSource ?: return@async entries + entries.map { entry -> + onProgress(BuildProgress(BuildProgress.Stage.DETAILS, detailed.incrementAndGet(), batch.size)) + runCatching { + val update = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { + source.getMangaUpdate(entry.toSManga(), emptyList(), fetchDetails = true, fetchChapters = true) + } ?: return@runCatching entry + val genres = update.manga.getGenres().orEmpty() + val raw = update.chapters.map { it.date_upload } + val dates = honestUploadDates(raw) + val names = update.chapters.map { it.name } + entry.copy( + datesUntrusted = uploadDatesUntrusted(raw), + oneShotChapters = names.isNotEmpty() && names.count { Recommended.ONE_SHOT.containsMatchIn(it) } * 2 >= names.size, + title = update.manga.title.ifBlank { entry.title }, + thumbnail = update.manga.thumbnail_url ?: entry.thumbnail, + // AniList's genres and the source's: more is known, not less. + tags = (entry.takeIf { it.via != null }?.tags.orEmpty() + genres).ifEmpty { entry.tags }, + chapters = update.chapters.size, + firstUpload = dates.minOrNull(), + lastUpload = dates.maxOrNull(), + completed = update.manga.status == SManga.COMPLETED || update.manga.status == SManga.PUBLISHING_FINISHED, + fetched = true, + ) + }.onFailure { Logger.w(it) { "Recommendation details failed for ${entry.title} on ${source.name}" } } + .getOrDefault(entry) + } + } + }.awaitAll().flatten() + } + + private suspend fun LibraryManga.toRecommended(): Recommended { + val raw = getChapter.awaitAll(manga.id!!, false).map { it.date_upload } + val dates = honestUploadDates(raw) + return Recommended( + datesUntrusted = uploadDatesUntrusted(raw), + sourceId = manga.source, + url = manga.url, + title = manga.title, + thumbnail = manga.thumbnail_url, + tags = manga.getOriginalGenres().orEmpty(), + score = 0f, + because = emptyList(), + chapters = totalChapters, + firstUpload = dates.minOrNull(), + lastUpload = dates.maxOrNull(), + completed = manga.status == SManga.COMPLETED || manga.status == SManga.PUBLISHING_FINISHED, + fromLibrary = true, + fetched = true, + ) + } + + private fun Recommended.toSManga() = SManga.create().also { + it.url = url + it.title = title + it.thumbnail_url = thumbnail + } + + /** + * Turns on the option that spells [tag] the way this source does, or returns false when the + * source has no such option. Sources name genres in their own language, so the canonical + * name is tried first and an approximate match is allowed after; "Status: Completed" never + * comes close to a genre. + */ + private fun FilterList.select(tag: String): Boolean { + fun like(name: String) = canonicalTag(name).equals(tag, true) || + normalizedLevenshteinSimilarity(name.lowercase(), tag.lowercase()) >= TAG_MATCH + for (filter in this) { + when (filter) { + is Filter.Group<*> -> for (item in filter.state) { + when { + item is Filter.CheckBox && like(item.name) -> return true.also { item.state = true } + item is Filter.TriState && like(item.name) -> return true.also { item.state = Filter.TriState.STATE_INCLUDE } + } + } + is Filter.Select<*> -> filter.values.indexOfFirst { like(it.toString()) } + .takeIf { it >= 0 }?.let { filter.state = it; return true } + else -> {} + } + } + return false + } + + companion object { + /** A tag with at least this much of the top tag's weight is searched for. */ + private const val TAG_MIN_WEIGHT = 0.15f + + /** A sanity ceiling on tags searched, however wide the profile. */ + private const val TAG_CAP = 60 + + + /** Series read per batch. A batch is one unit of pause, progress and persistence. */ + const val BATCH = 50 + + /** The top format must outweigh the next this many times before results are kept to its country. */ + private const val COUNTRY_DOMINANCE = 2f + + /** + * How many sources' copies of one series are detailed, so the one with the most chapters + * can be picked. Requests per build ≤ [SHORTLIST] × this, spread over sources. + */ + private const val COPIES = 2 + + /** How close a source's option name must be to a tag to count as the same genre. */ + private const val TAG_MATCH = 0.85 + + /** One request's allowance. A source that hangs costs this, not the build. */ + private const val REQUEST_TIMEOUT_MS = 30_000L + + private const val ANILIST = "https://graphql.anilist.co/" + + /** Aliased searches per GraphQL request. AniList's complexity limit allows a few dozen. */ + private const val POPULARITY_BATCH = 25 + + /** How alike the found title must be to the candidate's before its numbers are trusted. */ + private const val POPULARITY_MATCH = 0.75 + + // ponytail: exact key after stripping case and punctuation; Levenshtein against the whole + // library is the upgrade if "Solo Leveling" and "Solo Leveling (Official)" keep coexisting. + fun String.key() = lowercase().filter { it.isLetterOrDigit() } + } +} diff --git a/app/src/main/java/karasu/domain/recommendation/DiscardRecommendations.kt b/app/src/main/java/karasu/domain/recommendation/DiscardRecommendations.kt new file mode 100644 index 0000000000..182103b591 --- /dev/null +++ b/app/src/main/java/karasu/domain/recommendation/DiscardRecommendations.kt @@ -0,0 +1,31 @@ +package karasu.domain.recommendation + +import karasu.data.DatabaseHandler +import karasu.domain.manga.interactor.GetManga + +/** + * Throws the last build away before a new one: the list, and the manga rows the window created + * for it. Browsing inserts a row per card shown, so a build leaves a few hundred rows behind; + * kept, they are what makes a stale card reappear looking current. Rows that were added to the + * library, or read, are not the window's to remove and stay. + */ +class DiscardRecommendations( + private val store: RecommendationStore, + private val getManga: GetManga, + private val handler: DatabaseHandler, +) { + suspend fun await() { + val old = store.read() + store.clear() + if (old == null) return + val ids = old.items + .filterNot { it.fromLibrary } + .mapNotNull { runCatching { getManga.awaitByUrlAndSource(it.url, it.sourceId) }.getOrNull() } + .filterNot { it.favorite } + .mapNotNull { it.id } + if (ids.isEmpty()) return + runCatching { + handler.await { mangasQueries.deleteNotInLibraryAndNotReadByIds(ids) } + } + } +} diff --git a/app/src/main/java/karasu/presentation/category/CategoryRuleScreen.kt b/app/src/main/java/karasu/presentation/category/CategoryRuleScreen.kt index 7d765c5536..bea8740d30 100644 --- a/app/src/main/java/karasu/presentation/category/CategoryRuleScreen.kt +++ b/app/src/main/java/karasu/presentation/category/CategoryRuleScreen.kt @@ -10,10 +10,8 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape @@ -39,7 +37,6 @@ import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -57,7 +54,6 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.pluralStringResource import dev.icerock.moko.resources.compose.stringResource @@ -66,6 +62,12 @@ import karasu.domain.category.models.RuleCondition import karasu.domain.category.models.RuleField import karasu.domain.category.models.RuleOperator import karasu.i18n.MR +import karasu.presentation.core.enterAlwaysAppBarScrollBehavior +import karasu.presentation.KarasuScaffold +import karasu.presentation.AppBarType +import eu.kanade.tachiyomi.util.compose.LocalBackPress +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.foundation.lazy.rememberLazyListState import kotlinx.coroutines.launch /** How much a switched-off rule fades, while staying legible and editable. */ @@ -95,42 +97,30 @@ fun CategoryRuleScreen( val scope = rememberCoroutineScope() val deletedMessage = stringResource(MR.strings.category_rule_deleted) val undoLabel = stringResource(MR.strings.undo) + val onBack = LocalBackPress.current ?: {} + val listState = rememberLazyListState() - Box(modifier = Modifier.fillMaxSize()) { + // The same small app bar every other pushed screen has: back arrow, the category as title, + // "Rules" underneath. Nothing invented to stand in for it. + KarasuScaffold( + onNavigationIconClicked = onBack, + title = categoryName, + appBarType = AppBarType.SMALL, + scrollBehavior = enterAlwaysAppBarScrollBehavior( + canScroll = { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 }, + ), + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> LazyColumn( - // The screen has no app bar, so without this the category name draws under the status bar. + state = listState, modifier = Modifier - .fillMaxWidth() - .statusBarsPadding(), + .fillMaxSize() + .padding(innerPadding), contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { Column { - // A real header, not a bare line of text. Category names are often a single - // emoji or two words, which floating loose at the top of a screen reads as - // debris rather than as a title — the overline and the filled bar are what - // say "these are the rules of this category". - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier.fillMaxWidth(), - ) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { - Text( - text = stringResource(MR.strings.category_rules), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - Text( - text = categoryName, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } Text( text = stringResource( if (transitions.isEmpty()) MR.strings.category_rules_empty @@ -210,15 +200,6 @@ fun CategoryRuleScreen( } } } - - SnackbarHost( - hostState = snackbarHostState, - // Same reason the list needs statusBarsPadding: no scaffold here, so the undo action - // would otherwise sit under the navigation bar and be unreachable. - modifier = Modifier - .align(Alignment.BottomCenter) - .navigationBarsPadding(), - ) } } @@ -347,23 +328,32 @@ private fun RuleHeader( .padding(end = 4.dp) .semantics { contentDescription = toggleDescription }, ) - IconButton(onClick = { onMove(position - 1) }, enabled = position > 0) { - Icon( - Icons.Default.KeyboardArrowUp, - contentDescription = stringResource(MR.strings.category_rule_move_up), - ) - } - IconButton(onClick = { onMove(position + 1) }, enabled = position < count - 1) { - Icon( - Icons.Default.KeyboardArrowDown, - contentDescription = stringResource(MR.strings.category_rule_move_down), - ) - } - IconButton(onClick = onDelete) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(MR.strings.category_rule_delete), - ) + // Reorder and delete are rare next to enable/disable; behind the overflow, as the rest of + // the app keeps its secondary actions, the header stops reading as a toolbar. + var menu by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { menu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = stringResource(MR.strings.more)) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem( + text = { Text(stringResource(MR.strings.category_rule_move_up)) }, + leadingIcon = { Icon(Icons.Default.KeyboardArrowUp, contentDescription = null) }, + enabled = position > 0, + onClick = { menu = false; onMove(position - 1) }, + ) + DropdownMenuItem( + text = { Text(stringResource(MR.strings.category_rule_move_down)) }, + leadingIcon = { Icon(Icons.Default.KeyboardArrowDown, contentDescription = null) }, + enabled = position < count - 1, + onClick = { menu = false; onMove(position + 1) }, + ) + DropdownMenuItem( + text = { Text(stringResource(MR.strings.category_rule_delete)) }, + leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) }, + onClick = { menu = false; onDelete() }, + ) + } } } } diff --git a/app/src/main/java/karasu/presentation/library/ReleaseCalendarScreen.kt b/app/src/main/java/karasu/presentation/library/ReleaseCalendarScreen.kt index 3571f7aeb5..a9522f712b 100644 --- a/app/src/main/java/karasu/presentation/library/ReleaseCalendarScreen.kt +++ b/app/src/main/java/karasu/presentation/library/ReleaseCalendarScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow @@ -36,6 +35,33 @@ import karasu.domain.manga.interval.ReleaseSchedule import karasu.domain.manga.interval.ScheduledRelease import karasu.domain.manga.models.cover import karasu.i18n.MR +import karasu.domain.manga.interval.ReleaseMonth +import java.time.YearMonth +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip +import androidx.compose.runtime.setValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.material3.LocalContentColor +import androidx.compose.material.icons.outlined.ViewList +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import karasu.presentation.core.enterAlwaysAppBarScrollBehavior +import karasu.presentation.KarasuScaffold +import karasu.presentation.AppBarType +import eu.kanade.tachiyomi.util.compose.LocalBackPress +import androidx.compose.foundation.lazy.rememberLazyListState import karasu.presentation.manga.components.MangaCover import karasu.presentation.manga.components.MangaCoverRatio @@ -57,86 +83,145 @@ private val COVER_WIDTH = 96.dp fun ReleaseCalendarScreen( schedule: ReleaseSchedule?, calendar: ReleaseCalendar?, + month: ReleaseMonth?, + monthView: Boolean, + onMonthViewChange: (Boolean) -> Unit, + onlyCaughtUp: Boolean, + onOnlyCaughtUpChange: (Boolean) -> Unit, onMangaClick: (Manga) -> Unit, onSettingsClick: () -> Unit, contentPadding: PaddingValues = PaddingValues(0.dp), ) { - if (schedule == null || calendar == null) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - return - } + val onBack = LocalBackPress.current ?: {} + val listState = rememberLazyListState() - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding(), - contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 4.dp, top = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(MR.strings.release_calendar), - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.weight(1f), + // The same small app bar every other pushed screen has; the settings shortcut is its action. + KarasuScaffold( + onNavigationIconClicked = onBack, + title = stringResource(MR.strings.release_calendar), + appBarType = AppBarType.SMALL, + scrollBehavior = enterAlwaysAppBarScrollBehavior( + canScroll = { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 }, + ), + actions = { + // Two toggles that change what is on screen, then the settings that change how it + // is computed. Toggles are filled when on, the way filter icons read elsewhere. + IconButton(onClick = { onOnlyCaughtUpChange(!onlyCaughtUp) }) { + Icon( + imageVector = if (onlyCaughtUp) Icons.Filled.CheckCircle else Icons.Outlined.CheckCircle, + contentDescription = stringResource(MR.strings.release_calendar_only_caught_up), + tint = if (onlyCaughtUp) MaterialTheme.colorScheme.primary else LocalContentColor.current, + ) + } + IconButton(onClick = { onMonthViewChange(!monthView) }) { + Icon( + imageVector = if (monthView) Icons.Outlined.ViewList else Icons.Outlined.CalendarMonth, + contentDescription = stringResource(if (monthView) MR.strings.release_calendar_list_view else MR.strings.release_calendar_month_view), + ) + } + IconButton(onClick = onSettingsClick) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(MR.strings.settings), ) - IconButton(onClick = onSettingsClick) { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = stringResource(MR.strings.settings), + } + }, + ) { innerPadding -> + if (schedule == null || calendar == null || month == null) { + Box(modifier = Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@KarasuScaffold + } + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .padding(innerPadding), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // What landed today comes first: "did it come out?" is the question the day starts with. + if (schedule.arrived.isNotEmpty()) { + item { + SectionHeader(title = stringResource(MR.strings.release_calendar_arrived)) + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(schedule.arrived, key = { "arrived-" + (it.id ?: it.url) }) { manga -> + MangaCard(manga = manga, hint = "✓", onClick = { onMangaClick(manga) }) + } + } + } + } + + if (monthView) { + item { + MonthGrid(month = month, onMangaClick = onMangaClick) + } + } else { + items(calendar.days, key = { it.date.toString() }) { day -> + DaySection( + title = day.date.label(), + releases = day.releases, + onMangaClick = onMangaClick, ) } } - } - items(calendar.days, key = { it.date.toString() }) { day -> - DaySection( - title = day.date.label(), - releases = day.releases, - onMangaClick = onMangaClick, - ) - } + if (calendar.later.isNotEmpty()) { + item { + DaySection( + title = stringResource(MR.strings.release_calendar_later), + releases = calendar.later, + onMangaClick = onMangaClick, + ) + } + } - if (calendar.later.isNotEmpty()) { - item { - DaySection( - title = stringResource(MR.strings.release_calendar_later), - releases = calendar.later, - onMangaClick = onMangaClick, - ) + if (schedule.onHiatus.isNotEmpty()) { + item { + SectionHeader( + title = stringResource(MR.strings.release_calendar_hiatus), + subtitle = stringResource(MR.strings.release_calendar_hiatus_summary), + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(schedule.onHiatus, key = { "hiatus-" + (it.id ?: it.url) }) { manga -> + MangaCard(manga = manga, hint = null, onClick = { onMangaClick(manga) }) + } + } + } } - } - if (schedule.stalled.isNotEmpty()) { - item { - DaySection( - title = stringResource(MR.strings.release_calendar_stalled), - subtitle = stringResource(MR.strings.release_calendar_stalled_summary), - releases = schedule.stalled, - onMangaClick = onMangaClick, - ) + if (schedule.stalled.isNotEmpty()) { + item { + DaySection( + title = stringResource(MR.strings.release_calendar_stalled), + subtitle = stringResource(MR.strings.release_calendar_stalled_summary), + releases = schedule.stalled, + onMangaClick = onMangaClick, + ) + } } - } - if (schedule.unknown.isNotEmpty()) { - item { - SectionHeader( - title = stringResource(MR.strings.release_calendar_unknown), - subtitle = stringResource(MR.strings.release_calendar_unknown_summary), - ) - LazyRow( - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(schedule.unknown, key = { it.id ?: it.url }) { manga -> - MangaCard(manga = manga, hint = null, onClick = { onMangaClick(manga) }) + if (schedule.unknown.isNotEmpty()) { + item { + SectionHeader( + title = stringResource(MR.strings.release_calendar_unknown), + subtitle = stringResource(MR.strings.release_calendar_unknown_summary), + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(schedule.unknown, key = { it.id ?: it.url }) { manga -> + MangaCard(manga = manga, hint = null, onClick = { onMangaClick(manga) }) + } } } } @@ -169,7 +254,9 @@ private fun DaySection( items(releases, key = { it.manga.id ?: it.manga.url }) { release -> MangaCard( manga = release.manga, - hint = release.estimate.accuracyLabel(), + // A guess borrowed from the source's other series says so, instead of + // dressing up as a measurement with a "±" on it. + hint = if (release.guessed) stringResource(MR.strings.release_calendar_probably) else release.estimate.accuracyLabel(), onClick = { onMangaClick(release.manga) }, ) } @@ -257,3 +344,80 @@ private fun karasu.domain.manga.interval.ReleaseEstimate.accuracyLabel(): String else -> stringResource(MR.strings.release_calendar_give_or_take_days, days.toString()) } } + +/** + * The month as a grid: a cell per day, a dot per expected release, the day's covers below the + * grid for whichever day is tapped. Today is tapped by default, so the grid opens on something. + */ +@Composable +private fun MonthGrid(month: ReleaseMonth, onMangaClick: (Manga) -> Unit) { + val today = LocalDate.now() + var selected by remember(month.month) { mutableStateOf(if (today.yearMonth == month.month) today else month.month.atDay(1)) } + val byDate = month.days.associateBy { it.date } + // Cells before the first of the month, so weekdays line up with their column. + val firstColumn = (month.days.first().date.dayOfWeek.value % 7) + val cells: List = List(firstColumn) { null } + month.days.map { it.date } + val rows = cells.chunked(7) + + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = month.month.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) + .replaceFirstChar { it.titlecase(Locale.getDefault()) } + " " + month.month.year, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 4.dp), + ) + Row(modifier = Modifier.fillMaxWidth()) { + for (i in 0 until 7) { + Text( + text = java.time.DayOfWeek.of(if (i == 0) 7 else i).getDisplayName(TextStyle.NARROW, Locale.getDefault()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f), + ) + } + } + rows.forEach { row -> + Row(modifier = Modifier.fillMaxWidth()) { + row.forEach { date -> DayCell(date, byDate[date]?.releases?.size ?: 0, date == selected, date == today) { date?.let { selected = it } } } + repeat(7 - row.size) { Spacer(modifier = Modifier.weight(1f)) } + } + } + val releases = byDate[selected]?.releases.orEmpty() + DaySection(title = selected.label(), releases = releases, onMangaClick = onMangaClick) + } +} + +@Composable +private fun RowScope.DayCell(date: LocalDate?, count: Int, selected: Boolean, isToday: Boolean, onClick: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .padding(2.dp) + .clip(RoundedCornerShape(8.dp)) + .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent) + .then(if (date != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(vertical = 6.dp), + ) { + Text( + text = date?.dayOfMonth?.toString().orEmpty(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + ) + // Up to three dots; past that the number says it. + Row(horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.height(8.dp)) { + if (count in 1..3) repeat(count) { Dot() } + if (count > 3) Text(text = count.toString(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + } + } +} + +@Composable +private fun Dot() { + Box(modifier = Modifier.size(5.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary)) +} + +private val LocalDate.yearMonth: YearMonth get() = YearMonth.from(this) diff --git a/app/src/main/res/menu/browse_source.xml b/app/src/main/res/menu/browse_source.xml index 25aa82791d..2f9532145e 100644 --- a/app/src/main/res/menu/browse_source.xml +++ b/app/src/main/res/menu/browse_source.xml @@ -28,6 +28,30 @@ android:title="@string/open_in_webview" app:showAsAction="ifRoom" /> + + + + + + + + @color/backgroundDoki @color/colorOnDownloadBadgeDayNight + +