diff --git a/.github/nightly-notes/2026-10-28-fix-premiere-date-bell-shorts-home-force-dark.md b/.github/nightly-notes/2026-10-28-fix-premiere-date-bell-shorts-home-force-dark.md new file mode 100644 index 0000000000..2c9fea7db2 --- /dev/null +++ b/.github/nightly-notes/2026-10-28-fix-premiere-date-bell-shorts-home-force-dark.md @@ -0,0 +1,8 @@ +## New +- Choose whether playback keeps going after you swipe Flow away (#1468) + +## Fixed +- Premiere and stream dates fit in the description again (#1466) +- The notification bell works in search, Shorts, related channels and the video menu (#1467) +- Shorts stay hidden on a channel's Home tab and in Explore when Show Shorts is off (#1507) +- Titles and messages stay readable when the phone is in dark mode and Flow is light (#1517) diff --git a/app/src/main/java/io/github/aedev/flow/MainActivity.kt b/app/src/main/java/io/github/aedev/flow/MainActivity.kt index 0498c62594..6b89089a3c 100644 --- a/app/src/main/java/io/github/aedev/flow/MainActivity.kt +++ b/app/src/main/java/io/github/aedev/flow/MainActivity.kt @@ -150,6 +150,8 @@ class MainActivity : ComponentActivity() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { window.isNavigationBarContrastEnforced = false window.isStatusBarContrastEnforced = false + // Some MIUI builds read force dark from the view rather than the theme (#1517). + window.decorView.isForceDarkAllowed = false } // Player setup reads DataStore and opens the media cache index, so it runs off the main diff --git a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt index 6955d96af6..ecfa7c2826 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt @@ -57,6 +57,9 @@ const val MAX_CONCURRENT_DOWNLOADS = 5 /** On until turned off, matching NewPipe's default and the Background button (#1298). */ const val DEFAULT_BACKGROUND_PLAY_ENABLED = true +/** On until turned off: a swipe from recents has kept playback going since #1321. */ +const val DEFAULT_KEEP_PLAYING_AFTER_SWIPE = true + val DEFAULT_NAV_TAB_ORDER = listOf(0, 1, 2, 3, 4, 5, 6, 7) private const val MAX_UNPLAYABLE_VIDEO_IDS = 1_000 @@ -79,6 +82,7 @@ class PlayerPreferences( private object Keys { val BACKGROUND_PLAY_ENABLED = booleanPreferencesKey("background_play_enabled") + val KEEP_PLAYING_AFTER_SWIPE = booleanPreferencesKey("keep_playing_after_swipe") val AUTOPLAY_ENABLED = booleanPreferencesKey("autoplay_enabled") val QUEUE_AUTOPLAY_ENABLED = booleanPreferencesKey("queue_autoplay_enabled") val START_VIDEOS_PAUSED = booleanPreferencesKey("start_videos_paused") @@ -1652,6 +1656,19 @@ class PlayerPreferences( } } + /** Whether swiping Flow away from recent apps leaves a playing video or song going (#1468). */ + val keepPlayingAfterSwipe: Flow = + context.playerPreferencesDataStore.data + .map { preferences -> + preferences[Keys.KEEP_PLAYING_AFTER_SWIPE] ?: DEFAULT_KEEP_PLAYING_AFTER_SWIPE + } + + suspend fun setKeepPlayingAfterSwipe(enabled: Boolean) { + context.playerPreferencesDataStore.edit { preferences -> + preferences[Keys.KEEP_PLAYING_AFTER_SWIPE] = enabled + } + } + // Autoplay val autoplayEnabled: Flow = context.playerPreferencesDataStore.data 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..60782160b3 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,10 @@ 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.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -158,6 +162,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 */ @@ -279,6 +289,7 @@ class SubscriptionRepository private constructor( channelId: String, enabled: Boolean, ) { + var turnedOn = false context.subscriptionsDataStore.edit { preferences -> val channelData = preferences[channelKey(channelId)] if (channelData != null) { @@ -286,11 +297,20 @@ class SubscriptionRepository private constructor( if (subscription != null) { val updated = subscription.copy(isNotificationEnabled = enabled) preferences[channelKey(channelId)] = serializeChannel(updated) + turnedOn = enabled && !subscription.isNotificationEnabled } } } + if (turnedOn) bellsTurnedOn.tryEmit(channelId) } + // Every bell in the app writes through updateNotificationState, so this is the one place that + // knows a viewer just asked for alerts. Nothing replays: with no shell listening, it is dropped. + private val bellsTurnedOn = MutableSharedFlow(extraBufferCapacity = 1) + + /** A channel whose bell the viewer just turned on. */ + val notificationsTurnedOn: SharedFlow = bellsTurnedOn.asSharedFlow() + /** * Record that the subscription feed has just fetched these channels. * @@ -351,3 +371,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/data/shorts/FeedShelfShorts.kt b/app/src/main/java/io/github/aedev/flow/data/shorts/FeedShelfShorts.kt new file mode 100644 index 0000000000..3e8c0c8a2e --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/shorts/FeedShelfShorts.kt @@ -0,0 +1,21 @@ +package io.github.aedev.flow.data.shorts + +import io.github.aedev.flow.innertube.pages.renderer.FeedItem +import io.github.aedev.flow.innertube.pages.renderer.FeedShelf + +/** + * The shelves with every Short taken out, for a viewer who switched Shorts off (#1507). A shelf + * left with nothing is dropped rather than drawn as an empty header. + */ +fun List.withoutShorts(): List = + mapNotNull { shelf -> + val kept = shelf.items.filterNot(FeedItem::isShort) + when { + kept.size == shelf.items.size -> shelf + kept.isEmpty() -> null + else -> shelf.copy(items = kept) + } + } + +/** A collaboration reel arrives as a plain video lockup marked as a Short, so both shapes count. */ +fun FeedItem.isShort(): Boolean = this is FeedItem.ShortItem || (this is FeedItem.VideoItem && video.isShort) diff --git a/app/src/main/java/io/github/aedev/flow/player/LifecyclePlaybackPreferences.kt b/app/src/main/java/io/github/aedev/flow/player/LifecyclePlaybackPreferences.kt index 69b021c900..1e83bcd574 100644 --- a/app/src/main/java/io/github/aedev/flow/player/LifecyclePlaybackPreferences.kt +++ b/app/src/main/java/io/github/aedev/flow/player/LifecyclePlaybackPreferences.kt @@ -1,6 +1,7 @@ package io.github.aedev.flow.player import io.github.aedev.flow.data.local.DEFAULT_BACKGROUND_PLAY_ENABLED +import io.github.aedev.flow.data.local.DEFAULT_KEEP_PLAYING_AFTER_SWIPE import io.github.aedev.flow.data.local.PlayerPreferences import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -10,8 +11,9 @@ import javax.inject.Inject import javax.inject.Singleton /** - * The playback preferences read synchronously: by the activity lifecycle callbacks, and by opening a - * video, which has to know whether to start before any stream is handed to the player. + * The playback preferences read synchronously: by the activity lifecycle callbacks, by opening a + * video, which has to know whether to start before any stream is handed to the player, and by the + * media services when Flow is swiped away, which can happen after the activity is gone. */ data class LifecyclePlaybackSettings( val autoPipEnabled: Boolean = false, @@ -20,6 +22,7 @@ data class LifecyclePlaybackSettings( val shortsPipEnabled: Boolean = false, val startVideosPaused: Boolean = false, val openVideosFullscreen: Boolean = false, + val keepPlayingAfterSwipe: Boolean = DEFAULT_KEEP_PLAYING_AFTER_SWIPE, ) /** @@ -68,6 +71,11 @@ private fun PlayerPreferences.lifecyclePlaybackSettings(): Flow - settings.copy(startVideosPaused = startPaused, openVideosFullscreen = openFullscreen) + keepPlayingAfterSwipe, + ) { settings, startPaused, openFullscreen, keepPlaying -> + settings.copy( + startVideosPaused = startPaused, + openVideosFullscreen = openFullscreen, + keepPlayingAfterSwipe = keepPlaying, + ) } diff --git a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt index 159555039e..aaf2bffe8f 100644 --- a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt +++ b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt @@ -63,6 +63,7 @@ import io.github.aedev.flow.data.scrobble.ScrobbleRules import io.github.aedev.flow.extensions.setOffloadEnabled import io.github.aedev.flow.innertube.YouTube import io.github.aedev.flow.innertube.models.WatchEndpoint +import io.github.aedev.flow.player.LifecyclePlaybackPreferences import io.github.aedev.flow.player.MusicFailureStreak import io.github.aedev.flow.player.MusicLoadErrorPolicy import io.github.aedev.flow.player.MusicMutePause @@ -222,6 +223,9 @@ class Media3MusicService : MediaLibraryService() { @Inject lateinit var downloadUtil: DownloadUtil + @Inject + lateinit var lifecyclePlaybackPreferences: LifecyclePlaybackPreferences + @Inject lateinit var legacySongs: LegacySongDownloads @@ -531,7 +535,8 @@ class Media3MusicService : MediaLibraryService() { } } - if (!versionSwap && !videoId.isNullOrBlank() && !title.isNullOrBlank() && !artist.isNullOrBlank()) { + val wantsLyrics = !versionSwap && !videoId.isNullOrBlank() && !LocalMediaIds.isLocal(videoId) + if (wantsLyrics && !title.isNullOrBlank() && !artist.isNullOrBlank()) { lifecycleScope.launch(Dispatchers.IO) { try { Log.d(TAG, "Pre-warming lyrics cache in background for: $videoId - \"$title\"") @@ -1369,7 +1374,9 @@ class Media3MusicService : MediaLibraryService() { */ @OptIn(UnstableApi::class) override fun onTaskRemoved(rootIntent: Intent?) { - val keepsPlaying = ::player.isInitialized && player.keepsPlayingOnTaskRemoved(isPlaybackOngoing()) + val keepsPlaying = + ::player.isInitialized && + player.keepsPlayingOnTaskRemoved(lifecyclePlaybackPreferences.settings.keepPlayingAfterSwipe, isPlaybackOngoing()) PlayerDiagnostics.logInfo(TAG, "onTaskRemoved keepsPlaying=$keepsPlaying ${taskRemovalSnapshot()}") if (keepsPlaying) return pauseAllPlayersAndStopSelf() diff --git a/app/src/main/java/io/github/aedev/flow/service/PlaybackServiceEntryPoint.kt b/app/src/main/java/io/github/aedev/flow/service/PlaybackServiceEntryPoint.kt new file mode 100644 index 0000000000..fc630fecad --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/service/PlaybackServiceEntryPoint.kt @@ -0,0 +1,13 @@ +package io.github.aedev.flow.service + +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.github.aedev.flow.player.LifecyclePlaybackPreferences + +/** [VideoPlayerService] is not a Hilt entry point, so the singletons it reads come through here. */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface PlaybackServiceEntryPoint { + fun lifecyclePlaybackPreferences(): LifecyclePlaybackPreferences +} diff --git a/app/src/main/java/io/github/aedev/flow/service/TaskRemovalPolicy.kt b/app/src/main/java/io/github/aedev/flow/service/TaskRemovalPolicy.kt index 804666b42d..49d1166ab8 100644 --- a/app/src/main/java/io/github/aedev/flow/service/TaskRemovalPolicy.kt +++ b/app/src/main/java/io/github/aedev/flow/service/TaskRemovalPolicy.kt @@ -5,22 +5,29 @@ import androidx.media3.common.Player /** * Whether a media service stays up when Flow is swiped away from recents. Media3's own rule keeps it * only while a session is playing; this also keeps it while a requested song or video is still - * loading, so a swipe between tracks or during a stall does not end playback. A pause ends it. + * loading, so a swipe between tracks or during a stall does not end playback. A pause ends it, and so + * does every swipe for a viewer who turned [keepPlayingAfterSwipe] off (#1468). */ internal fun keepsPlayingOnTaskRemoved( + keepPlayingAfterSwipe: Boolean, playbackOngoing: Boolean, playWhenReady: Boolean, playbackState: Int, hasMedia: Boolean, ): Boolean = - playbackOngoing && + keepPlayingAfterSwipe && + playbackOngoing && hasMedia && playWhenReady && playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED -internal fun Player.keepsPlayingOnTaskRemoved(playbackOngoing: Boolean): Boolean = +internal fun Player.keepsPlayingOnTaskRemoved( + keepPlayingAfterSwipe: Boolean, + playbackOngoing: Boolean, +): Boolean = keepsPlayingOnTaskRemoved( + keepPlayingAfterSwipe = keepPlayingAfterSwipe, playbackOngoing = playbackOngoing, playWhenReady = playWhenReady, playbackState = playbackState, diff --git a/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt b/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt index 364de957f4..a82e42f6a5 100644 --- a/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt +++ b/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt @@ -12,6 +12,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService +import dagger.hilt.android.EntryPointAccessors import io.github.aedev.flow.R import io.github.aedev.flow.notification.NotificationHelper import io.github.aedev.flow.player.EnhancedPlayerManager @@ -163,8 +164,13 @@ class VideoPlayerService : MediaSessionService() { return } + val keepPlayingAfterSwipe = + EntryPointAccessors + .fromApplication(applicationContext, PlaybackServiceEntryPoint::class.java) + .lifecyclePlaybackPreferences() + .settings.keepPlayingAfterSwipe val player = EnhancedPlayerManager.getInstance().getPlayer() - if (player?.keepsPlayingOnTaskRemoved(isPlaybackOngoing()) == true) return + if (player?.keepsPlayingOnTaskRemoved(keepPlayingAfterSwipe, isPlaybackOngoing()) == true) return EnhancedPlayerManager.getInstance().stop() releaseLocks() diff --git a/app/src/main/java/io/github/aedev/flow/ui/FlowApp.kt b/app/src/main/java/io/github/aedev/flow/ui/FlowApp.kt index d18dbe6fca..0ca9662370 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/FlowApp.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/FlowApp.kt @@ -55,6 +55,7 @@ import io.github.aedev.flow.ui.components.musicplayer.sheet.rememberMusicPlayerS import io.github.aedev.flow.ui.components.shared.LocalMediaOpenOrigins import io.github.aedev.flow.ui.components.shared.MediaMiniBarDefaults import io.github.aedev.flow.ui.components.shared.MediaOpenOrigins +import io.github.aedev.flow.ui.components.shared.SubscriptionBellAlerts import io.github.aedev.flow.ui.components.shared.mediaMiniBarBounds import io.github.aedev.flow.ui.components.videoplayer.PlayerSheetValue import io.github.aedev.flow.ui.components.videoplayer.rememberPlayerDraggableState @@ -507,6 +508,7 @@ fun FlowApp( LocalFlowSnackbarHostState provides snackbarHostState, LocalStatusBarHero provides statusBarHero, ) { + if (needsOnboarding == false) SubscriptionBellAlerts(snackbarHostState) NavHost( navController = navController, startDestination = if (needsOnboarding == true) "onboarding" else defaultStartRoute, 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