Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions app/src/main/java/io/github/aedev/flow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -1652,6 +1656,19 @@ class PlayerPreferences(
}
}

/** Whether swiping Flow away from recent apps leaves a playing video or song going (#1468). */
val keepPlayingAfterSwipe: Flow<Boolean> =
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<Boolean> =
context.playerPreferencesDataStore.data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -158,6 +162,12 @@ class SubscriptionRepository private constructor(
}
}

/** The channels whose bell is on. */
fun notifyingChannelIds(): Flow<Set<String>> =
getAllSubscriptions()
.map(::notifyingIds)
.distinctUntilChanged()

/**
* Get all subscription IDs as a Set
*/
Expand Down Expand Up @@ -279,18 +289,28 @@ class SubscriptionRepository private constructor(
channelId: String,
enabled: Boolean,
) {
var turnedOn = false
context.subscriptionsDataStore.edit { preferences ->
val channelData = preferences[channelKey(channelId)]
if (channelData != null) {
val subscription = deserializeChannel(channelData)
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<String>(extraBufferCapacity = 1)

/** A channel whose bell the viewer just turned on. */
val notificationsTurnedOn: SharedFlow<String> = bellsTurnedOn.asSharedFlow()

/**
* Record that the subscription feed has just fetched these channels.
*
Expand Down Expand Up @@ -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<ChannelSubscription>): Set<String> =
subscriptions.filter { it.isNotificationEnabled }.mapTo(HashSet()) { it.channelId }
Original file line number Diff line number Diff line change
@@ -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<FeedShelf>.withoutShorts(): List<FeedShelf> =
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)
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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,
)

/**
Expand Down Expand Up @@ -68,6 +71,11 @@ private fun PlayerPreferences.lifecyclePlaybackSettings(): Flow<LifecyclePlaybac
},
startVideosPaused,
openVideosFullscreen,
) { settings, startPaused, openFullscreen ->
settings.copy(startVideosPaused = startPaused, openVideosFullscreen = openFullscreen)
keepPlayingAfterSwipe,
) { settings, startPaused, openFullscreen, keepPlaying ->
settings.copy(
startVideosPaused = startPaused,
openVideosFullscreen = openFullscreen,
keepPlayingAfterSwipe = keepPlaying,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -222,6 +223,9 @@ class Media3MusicService : MediaLibraryService() {
@Inject
lateinit var downloadUtil: DownloadUtil

@Inject
lateinit var lifecyclePlaybackPreferences: LifecyclePlaybackPreferences

@Inject
lateinit var legacySongs: LegacySongDownloads

Expand Down Expand Up @@ -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\"")
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/io/github/aedev/flow/ui/FlowApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ internal fun ChannelRow(
isSubscribed: Boolean? = null,
onSubscribeClick: () -> Unit = {},
onUnsubscribeClick: () -> Unit = {},
isNotificationsEnabled: Boolean = false,
onNotificationChange: ((Boolean) -> Unit)? = null,
) {
Row(
modifier =
Expand Down Expand Up @@ -82,6 +84,8 @@ internal fun ChannelRow(
isSubscribed = isSubscribed,
onSubscribeClick = onSubscribeClick,
onUnsubscribeClick = onUnsubscribeClick,
isNotificationsEnabled = isNotificationsEnabled,
onNotificationChange = onNotificationChange,
size = FlowSubscribeButtonSize.Compact,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Video> = emptyList(),
onVideoClick: (Video) -> Unit = {},
Expand Down Expand Up @@ -94,6 +96,8 @@ fun SearchChannelHeroCard(
ChannelActions(
isSubscribed = isSubscribed,
onSubscribeToggle = onSubscribeToggle,
isNotificationsEnabled = isNotificationsEnabled,
onNotificationChange = onNotificationChange,
onOpen = onClick,
tint = tint,
modifier = Modifier.width(InlineActionsColumn),
Expand All @@ -105,6 +109,8 @@ fun SearchChannelHeroCard(
ChannelActions(
isSubscribed = isSubscribed,
onSubscribeToggle = onSubscribeToggle,
isNotificationsEnabled = isNotificationsEnabled,
onNotificationChange = onNotificationChange,
onOpen = onClick,
tint = tint,
modifier = Modifier.fillMaxWidth().padding(horizontal = CardHorizontalPadding),
Expand Down Expand Up @@ -171,6 +177,8 @@ private fun ChannelIdentity(
private fun ChannelActions(
isSubscribed: Boolean,
onSubscribeToggle: () -> Unit,
isNotificationsEnabled: Boolean,
onNotificationChange: ((Boolean) -> Unit)?,
onOpen: () -> Unit,
tint: MediaArtworkTint,
modifier: Modifier = Modifier,
Expand All @@ -184,6 +192,8 @@ private fun ChannelActions(
isSubscribed = isSubscribed,
onSubscribeClick = onSubscribeToggle,
onUnsubscribeClick = onSubscribeToggle,
isNotificationsEnabled = isNotificationsEnabled,
onNotificationChange = onNotificationChange,
size = FlowSubscribeButtonSize.Wide,
tint = tint,
modifier = if (isSubscribed) Modifier else Modifier.weight(1f),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ data class SearchResultActions(
val dismissKeyboard: () -> Unit,
val isSubscribed: (String) -> Boolean = { false },
val onSubscribeToggle: (Channel) -> Unit = {},
val isNotifying: (String) -> Boolean = { false },
val onNotificationChange: (Channel, Boolean) -> Unit = { _, _ -> },
)

@Composable
Expand Down Expand Up @@ -92,6 +94,8 @@ fun SearchResults(
channel = item.channel,
isSubscribed = actions.isSubscribed(item.channel.id),
onSubscribeToggle = { actions.onSubscribeToggle(item.channel) },
isNotificationsEnabled = actions.isNotifying(item.channel.id),
onNotificationChange = { actions.onNotificationChange(item.channel, it) },
onClick = { actions.onChannelClick(item.channel) },
latestTitle = item.latestTitle,
latestVideos = item.latestVideos,
Expand Down
Loading
Loading