From 015fb703e95c1f46f35ad1ff7c4f6026eccf1c00 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:36:41 +0200 Subject: [PATCH 1/9] fix(description): fit premiere and stream dates in the date card --- .../components/shared/DescriptionFactoids.kt | 111 ++++++++++++++++++ .../shared/FlowDescriptionBottomSheet.kt | 29 +---- .../io/github/aedev/flow/utils/DateDisplay.kt | 15 +-- app/src/main/res/values/strings.xml | 2 + .../shared/DescriptionFactoidsTest.kt | 79 +++++++++++++ 5 files changed, 201 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/shared/DescriptionFactoids.kt create mode 100644 app/src/test/java/io/github/aedev/flow/ui/components/shared/DescriptionFactoidsTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/DescriptionFactoids.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/DescriptionFactoids.kt new file mode 100644 index 0000000000..1f2371e2ce --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/DescriptionFactoids.kt @@ -0,0 +1,111 @@ +package io.github.aedev.flow.ui.components.shared + +import android.text.format.DateFormat +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.github.aedev.flow.R +import io.github.aedev.flow.data.model.Video +import io.github.aedev.flow.innertube.YouTube +import io.github.aedev.flow.innertube.pages.VideoDescriptionFactoid +import io.github.aedev.flow.innertube.pages.VideoDescriptionPage +import io.github.aedev.flow.utils.DateDisplayMode +import io.github.aedev.flow.utils.DateFormatStyle +import io.github.aedev.flow.utils.exactUploadTimestamp +import io.github.aedev.flow.utils.formatLikeCount +import io.github.aedev.flow.utils.formatUploadDateConfigured +import io.github.aedev.flow.utils.resolveDisplayUploadTimestamp +import io.github.aedev.flow.utils.withoutStreamOrPremiereWords +import java.text.NumberFormat +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +internal enum class DescriptionDateKind { UPLOADED, PREMIERED, STREAMED } + +/** What the description's date card can say: a calendar day, or only an age. */ +internal sealed interface DescriptionDate { + data class OnDay( + val timestampMs: Long, + ) : DescriptionDate + + data class Aged( + val ageText: String, + val kind: DescriptionDateKind, + ) : DescriptionDate +} + +internal fun readDescriptionDate( + dateText: String?, + timestampMs: Long, + timestampIsExact: Boolean, + nowMillis: Long = System.currentTimeMillis(), + hl: String? = YouTube.locale.hl, +): DescriptionDate { + val resolved = resolveDisplayUploadTimestamp(dateText, timestampMs, nowMillis, hl) + val exact = exactUploadTimestamp(dateText, resolved, timestampMs, timestampIsExact, nowMillis, hl) + if (exact != null && exact > 0L) return DescriptionDate.OnDay(exact) + val text = dateText?.trim().orEmpty() + val kind = + when { + text.startsWith("premiered ", ignoreCase = true) -> DescriptionDateKind.PREMIERED + text.startsWith("streamed ", ignoreCase = true) -> DescriptionDateKind.STREAMED + else -> DescriptionDateKind.UPLOADED + } + return DescriptionDate.Aged(withoutStreamOrPremiereWords(text), kind) +} + +/** + * The three cards for a description the server sent no figures for, which is every premiere and past + * stream (#1466). The date takes the shape YouTube's app gives it, the day on top and the year below, + * because a whole "Premiered 2 days ago" does not fit a third of the row. + */ +@Composable +internal fun fallbackFactoids( + video: Video, + descriptionPage: VideoDescriptionPage?, +): List { + val locale = Locale.getDefault() + val dateText = descriptionPage?.publishedDateText ?: video.uploadDate + val date = + when (val read = readDescriptionDate(dateText, video.timestamp, video.timestampIsExact)) { + is DescriptionDate.OnDay -> { + val day = Instant.ofEpochMilli(read.timestampMs).atZone(ZoneId.systemDefault()) + VideoDescriptionFactoid( + value = DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, "MMMd"), locale).format(day), + label = DateTimeFormatter.ofPattern("y", locale).format(day), + ) + } + + is DescriptionDate.Aged -> { + VideoDescriptionFactoid( + value = + formatUploadDateConfigured( + date = read.ageText, + mode = DateDisplayMode.RELATIVE, + style = DateFormatStyle.SYSTEM, + timestampFallbackMs = video.timestamp, + locale = locale, + timestampIsExact = video.timestampIsExact, + ), + label = + when (read.kind) { + DescriptionDateKind.PREMIERED -> stringResource(R.string.description_premiered) + DescriptionDateKind.STREAMED -> stringResource(R.string.description_streamed) + DescriptionDateKind.UPLOADED -> stringResource(R.string.uploaded) + }, + ) + } + } + return listOf( + VideoDescriptionFactoid( + value = formatLikeCount(video.likeCount.toInt()), + label = stringResource(R.string.likes), + ), + VideoDescriptionFactoid( + value = NumberFormat.getIntegerInstance(locale).format(descriptionPage?.viewCount ?: video.viewCount), + label = stringResource(R.string.views), + ), + date, + ) +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/FlowDescriptionBottomSheet.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/FlowDescriptionBottomSheet.kt index 6910af21dc..10455de43e 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/FlowDescriptionBottomSheet.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/FlowDescriptionBottomSheet.kt @@ -31,20 +31,15 @@ import io.github.aedev.flow.R import io.github.aedev.flow.data.model.RichTextTarget import io.github.aedev.flow.data.model.Video import io.github.aedev.flow.data.model.plainRichText -import io.github.aedev.flow.innertube.pages.VideoDescriptionFactoid import io.github.aedev.flow.innertube.pages.VideoDescriptionPage import io.github.aedev.flow.ui.components.layout.navigation.LocalMediaNavigator import io.github.aedev.flow.ui.components.shared.FlowBottomSheet import io.github.aedev.flow.ui.components.shared.FlowSheetHeader import io.github.aedev.flow.ui.components.shared.card.LocalVideoCardPreferences import io.github.aedev.flow.ui.components.shared.defaultSheetExpandedHeight -import io.github.aedev.flow.ui.components.shared.rememberDateDisplaySettings import io.github.aedev.flow.ui.components.shared.rememberFlowBottomSheetState import io.github.aedev.flow.ui.components.shared.rememberRichTextInlineContent import io.github.aedev.flow.ui.openYouTubeUrl -import io.github.aedev.flow.utils.DateContext -import io.github.aedev.flow.utils.formatLikeCount -import io.github.aedev.flow.utils.formatViewCount import io.github.aedev.flow.utils.toAnnotatedString @OptIn(ExperimentalLayoutApi::class) @@ -173,29 +168,7 @@ fun FlowDescriptionBottomSheet( ) } - val dateSettings = rememberDateDisplaySettings() - val stats = - descriptionPage?.factoids?.takeIf { it.isNotEmpty() } - ?: listOf( - VideoDescriptionFactoid( - value = formatLikeCount(video.likeCount.toInt()), - label = stringResource(R.string.likes), - ), - VideoDescriptionFactoid( - value = formatViewCount(descriptionPage?.viewCount ?: video.viewCount), - label = stringResource(R.string.views), - ), - VideoDescriptionFactoid( - value = - dateSettings.format( - descriptionPage?.publishedDateText ?: video.uploadDate, - DateContext.DESCRIPTION, - video.timestamp, - video.timestampIsExact, - ), - label = stringResource(R.string.uploaded), - ), - ) + val stats = descriptionPage?.factoids?.takeIf { it.isNotEmpty() } ?: fallbackFactoids(video, descriptionPage) Row(horizontalArrangement = Arrangement.spacedBy(CardSpacing)) { stats.forEach { factoid -> diff --git a/app/src/main/java/io/github/aedev/flow/utils/DateDisplay.kt b/app/src/main/java/io/github/aedev/flow/utils/DateDisplay.kt index 19fdd0f181..a24c2fb5e7 100644 --- a/app/src/main/java/io/github/aedev/flow/utils/DateDisplay.kt +++ b/app/src/main/java/io/github/aedev/flow/utils/DateDisplay.kt @@ -231,6 +231,10 @@ private fun applyExactPrefix( return "$prefix $value" } +private val StreamOrPremiereWords = Regex("(?i)^(streamed|premiered)\\s+(live\\s+)?(on\\s+)?") + +internal fun withoutStreamOrPremiereWords(text: String): String = text.replace(StreamOrPremiereWords, "").trim() + fun parseToTimestamp( text: String?, hl: String? = YouTube.locale.hl, @@ -239,13 +243,10 @@ fun parseToTimestamp( if (raw.isEmpty()) return null raw.toLongOrNull()?.takeIf { it > 100_000_000_000L }?.let { return it } - val cleanRaw = - raw - // "Streamed live on Jun 19, 2020" and "Premiered on Jan 1, 2020" carry the date behind - // words no format string matches, so without dropping them the parse fails and the - // caller falls back to whatever timestamp it already had. - .replace(Regex("(?i)^(streamed|premiered)\\s+(live\\s+)?(on\\s+)?"), "") - .trim() + // "Streamed live on Jun 19, 2020" and "Premiered on Jan 1, 2020" carry the date behind words no + // format string matches, so without dropping them the parse fails and the caller falls back to + // whatever timestamp it already had. + val cleanRaw = withoutStreamOrPremiereWords(raw) val absFormats = listOf( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5966b9e5ce..f9f546fbaa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -287,6 +287,8 @@ Dislikes Channel ID Uploaded + Premiered + Streamed iTag Mime Type diff --git a/app/src/test/java/io/github/aedev/flow/ui/components/shared/DescriptionFactoidsTest.kt b/app/src/test/java/io/github/aedev/flow/ui/components/shared/DescriptionFactoidsTest.kt new file mode 100644 index 0000000000..9d46d56cb4 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/ui/components/shared/DescriptionFactoidsTest.kt @@ -0,0 +1,79 @@ +package io.github.aedev.flow.ui.components.shared + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.time.LocalDate +import java.time.ZoneId + +class DescriptionFactoidsTest { + private val now = + LocalDate + .of(2026, 10, 28) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + + private fun dayOf(read: DescriptionDate): LocalDate = + java.time.Instant + .ofEpochMilli((read as DescriptionDate.OnDay).timestampMs) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + + @Test + fun `a premiere with a date reads as that day`() { + val read = readDescriptionDate("Premiered Oct 24, 2026", 0L, false, now, "en") + + assertThat(dayOf(read)).isEqualTo(LocalDate.of(2026, 10, 24)) + } + + @Test + fun `a past stream with a date reads as that day`() { + val read = readDescriptionDate("Streamed live on Jul 12, 2022", 0L, false, now, "en") + + assertThat(dayOf(read)).isEqualTo(LocalDate.of(2022, 7, 12)) + } + + @Test + fun `a plain upload date reads as that day`() { + val read = readDescriptionDate("Oct 24, 2009", 0L, false, now, "en") + + assertThat(dayOf(read)).isEqualTo(LocalDate.of(2009, 10, 24)) + } + + @Test + fun `a premiere known only by age keeps the age and the kind apart`() { + val read = readDescriptionDate("Premiered 2 days ago", 0L, false, now, "en") + + assertThat(read).isEqualTo(DescriptionDate.Aged("2 days ago", DescriptionDateKind.PREMIERED)) + } + + @Test + fun `a stream known only by age is a stream`() { + val read = readDescriptionDate("Streamed 3 weeks ago", 0L, false, now, "en") + + assertThat(read).isEqualTo(DescriptionDate.Aged("3 weeks ago", DescriptionDateKind.STREAMED)) + } + + @Test + fun `an upload known only by age is an upload`() { + val read = readDescriptionDate("3 years ago", 0L, false, now, "en") + + assertThat(read).isEqualTo(DescriptionDate.Aged("3 years ago", DescriptionDateKind.UPLOADED)) + } + + @Test + fun `an exact timestamp places the day even behind an age`() { + val stamp = now - 2 * 24 * 60 * 60 * 1000L + + val read = readDescriptionDate("Premiered 2 days ago", stamp, true, now, "en") + + assertThat(read).isEqualTo(DescriptionDate.OnDay(stamp)) + } + + @Test + fun `text nothing can read stays as it came`() { + val read = readDescriptionDate("sometime", 0L, false, now, "en") + + assertThat(read).isEqualTo(DescriptionDate.Aged("sometime", DescriptionDateKind.UPLOADED)) + } +} From cc3f866c865366742c3403ae294e877819ad78ac Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:42:47 +0200 Subject: [PATCH 2/9] fix(subscriptions): make the bell work on every subscribe button --- .../flow/data/local/SubscriptionRepository.kt | 10 ++++ .../ui/components/channel/ChannelCards.kt | 4 ++ .../search/SearchChannelHeroCard.kt | 10 ++++ .../ui/components/search/SearchResults.kt | 4 ++ .../shared/CollaboratorsBottomSheet.kt | 4 ++ .../components/shared/FlowSubscribeButton.kt | 30 +++++++----- .../ui/components/shared/SubscriptionBell.kt | 48 +++++++++++++++++++ .../quickactions/VideoQuickActionsSheet.kt | 5 ++ .../shorts/ShortsMetadataOverlay.kt | 4 ++ .../ui/components/shorts/ShortsReelPage.kt | 5 ++ .../flow/ui/screens/channel/ChannelContent.kt | 4 ++ .../flow/ui/screens/channel/ChannelScreen.kt | 3 ++ .../ui/screens/channel/ChannelViewModel.kt | 14 ++++++ .../ui/screens/search/SearchResultsBody.kt | 8 +++- .../taste/DiscoverChannelsViewModel.kt | 3 +- .../components/shared/SubscriptionBellTest.kt | 21 ++++++++ 16 files changed, 163 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/shared/SubscriptionBell.kt create mode 100644 app/src/test/java/io/github/aedev/flow/ui/components/shared/SubscriptionBellTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/data/local/SubscriptionRepository.kt b/app/src/main/java/io/github/aedev/flow/data/local/SubscriptionRepository.kt index 97edbeef0b..57dda19d3f 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/SubscriptionRepository.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/SubscriptionRepository.kt @@ -11,6 +11,7 @@ import io.github.aedev.flow.data.local.AppDatabase import io.github.aedev.flow.data.profile.StoreScope import io.github.aedev.flow.utils.ThumbnailUrlResolver import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -158,6 +159,12 @@ class SubscriptionRepository private constructor( } } + /** The channels whose bell is on. */ + fun notifyingChannelIds(): Flow> = + getAllSubscriptions() + .map(::notifyingIds) + .distinctUntilChanged() + /** * Get all subscription IDs as a Set */ @@ -351,3 +358,6 @@ data class ChannelSubscription( /** When the subscription feed last fetched this channel; 0 means never. */ val lastFeedFetchAt: Long = 0L, ) + +internal fun notifyingIds(subscriptions: List): Set = + subscriptions.filter { it.isNotificationEnabled }.mapTo(HashSet()) { it.channelId } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/channel/ChannelCards.kt b/app/src/main/java/io/github/aedev/flow/ui/components/channel/ChannelCards.kt index 8286caa9fa..8e75981e63 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/channel/ChannelCards.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/channel/ChannelCards.kt @@ -41,6 +41,8 @@ internal fun ChannelRow( isSubscribed: Boolean? = null, onSubscribeClick: () -> Unit = {}, onUnsubscribeClick: () -> Unit = {}, + isNotificationsEnabled: Boolean = false, + onNotificationChange: ((Boolean) -> Unit)? = null, ) { Row( modifier = @@ -82,6 +84,8 @@ internal fun ChannelRow( isSubscribed = isSubscribed, onSubscribeClick = onSubscribeClick, onUnsubscribeClick = onUnsubscribeClick, + isNotificationsEnabled = isNotificationsEnabled, + onNotificationChange = onNotificationChange, size = FlowSubscribeButtonSize.Compact, ) } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/search/SearchChannelHeroCard.kt b/app/src/main/java/io/github/aedev/flow/ui/components/search/SearchChannelHeroCard.kt index 0f1b8f0ce8..174e90ec8c 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/search/SearchChannelHeroCard.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/search/SearchChannelHeroCard.kt @@ -56,6 +56,8 @@ fun SearchChannelHeroCard( onSubscribeToggle: () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier, + isNotificationsEnabled: Boolean = false, + onNotificationChange: ((Boolean) -> Unit)? = null, latestTitle: String? = null, latestVideos: List