diff --git a/.github/nightly-notes/2026-10-27-feat-downloads-tab-share-swipe-delete.md b/.github/nightly-notes/2026-10-27-feat-downloads-tab-share-swipe-delete.md
new file mode 100644
index 0000000000..460450e095
--- /dev/null
+++ b/.github/nightly-notes/2026-10-27-feat-downloads-tab-share-swipe-delete.md
@@ -0,0 +1,9 @@
+## New
+- Downloads tab: turn it on in Settings › Appearance › Navigation bar
+- Download with Flow in the share menu opens the download menu for a shared video
+- Swipe a download away to delete it, with Undo (#1415)
+- Ask before deleting setting: turn it off to delete from the menu without a dialog (#1415)
+- Storage summary setting hides the space used and free in Downloads
+
+## Improved
+- Deleting downloads now shows Undo (#1415)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index dbf26e8831..7bb65f4867 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -402,6 +402,19 @@
android:resource="@xml/widget_downloads_info" />
+
+
+
+
+
+
+
+
+
=
+ context.playerPreferencesDataStore.data
+ .map { preferences -> preferences[Keys.DOWNLOADS_NAV_TAB_ENABLED] ?: false }
+
+ suspend fun setDownloadsNavigationEnabled(enabled: Boolean) {
+ context.playerPreferencesDataStore.edit { preferences ->
+ preferences[Keys.DOWNLOADS_NAV_TAB_ENABLED] = enabled
+ }
+ }
+
// Continue Watching shelf enabled preference
val continueWatchingEnabled: Flow =
context.playerPreferencesDataStore.data
@@ -3214,6 +3227,28 @@ class PlayerPreferences(
}
}
+ /** The space used and free, shown at the top of the Downloads page. */
+ val downloadsStorageCard: Flow =
+ context.playerPreferencesDataStore.data
+ .map { preferences -> preferences[Keys.DOWNLOADS_STORAGE_CARD] ?: true }
+
+ suspend fun setDownloadsStorageCard(enabled: Boolean) {
+ context.playerPreferencesDataStore.edit { preferences ->
+ preferences[Keys.DOWNLOADS_STORAGE_CARD] = enabled
+ }
+ }
+
+ /** Delete in the Downloads menus asks first; a swipe never does, its snackbar can undo it. */
+ val downloadsConfirmDelete: Flow =
+ context.playerPreferencesDataStore.data
+ .map { preferences -> preferences[Keys.DOWNLOADS_CONFIRM_DELETE] ?: true }
+
+ suspend fun setDownloadsConfirmDelete(enabled: Boolean) {
+ context.playerPreferencesDataStore.edit { preferences ->
+ preferences[Keys.DOWNLOADS_CONFIRM_DELETE] = enabled
+ }
+ }
+
/** Whether a video the viewer opens is also saved for offline, and on which networks. */
val autoDownloadOpenedVideos: Flow =
context.playerPreferencesDataStore.data
diff --git a/app/src/main/java/io/github/aedev/flow/di/ApplicationScopeModule.kt b/app/src/main/java/io/github/aedev/flow/di/ApplicationScopeModule.kt
new file mode 100644
index 0000000000..22774e6e4a
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/di/ApplicationScopeModule.kt
@@ -0,0 +1,25 @@
+package io.github.aedev.flow.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+/** For work a screen starts that must finish after the screen is gone, such as a delete it held back for Undo. */
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class ApplicationScope
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ApplicationScopeModule {
+ @Provides
+ @Singleton
+ @ApplicationScope
+ fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+}
diff --git a/app/src/main/java/io/github/aedev/flow/ui/AppHooks.kt b/app/src/main/java/io/github/aedev/flow/ui/AppHooks.kt
index ff0fb250f3..7547835874 100644
--- a/app/src/main/java/io/github/aedev/flow/ui/AppHooks.kt
+++ b/app/src/main/java/io/github/aedev/flow/ui/AppHooks.kt
@@ -15,6 +15,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import io.github.aedev.flow.R
import io.github.aedev.flow.data.shorts.queue.ShortsQueueSource
+import io.github.aedev.flow.ui.components.layout.navigation.DOWNLOADS_ROUTE
+import io.github.aedev.flow.ui.components.layout.navigation.DOWNLOADS_TAB_ROUTE
import io.github.aedev.flow.utils.NetworkConnectivityObserver
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
@@ -33,6 +35,7 @@ fun HandlePendingRoute(
pendingRoute: String?,
navController: NavController,
startRoute: String,
+ downloadsTab: suspend () -> Boolean,
onConsumed: () -> Unit,
onBeforeNavigate: () -> Unit = {},
) {
@@ -40,7 +43,7 @@ fun HandlePendingRoute(
pendingRoute?.let { route ->
navController.awaitGraph()
onBeforeNavigate()
- val tab = flowTabForRoute(route)
+ val tab = flowTabForRoute(route, downloadsTab())
when {
tab != null -> navController.navigateToTab(tab, startRoute)
else -> navController.openPage(route)
@@ -87,10 +90,11 @@ private const val OFFLINE_NOTICE_DELAY_MS = 3_000L
@Composable
fun OfflineMonitor(
context: Context,
- navController: NavController,
snackbarHostState: SnackbarHostState,
currentRoute: State,
+ onOpenDownloads: () -> Unit,
) {
+ val openDownloads by rememberUpdatedState(onOpenDownloads)
val connectivity = remember(context) { NetworkConnectivityObserver(context) }
val isConnected by remember(connectivity) { connectivity.observeConnectivity() }
.collectAsStateWithLifecycle(initialValue = true)
@@ -100,7 +104,8 @@ fun OfflineMonitor(
if (isConnected) return@LaunchedEffect
val isSafeRoute =
- route == "downloads" ||
+ route == DOWNLOADS_ROUTE ||
+ route == DOWNLOADS_TAB_ROUTE ||
route.startsWith("player") ||
route.startsWith("musicPlayer") ||
route == "settings" ||
@@ -116,9 +121,7 @@ fun OfflineMonitor(
duration = SnackbarDuration.Short,
)
if (result == SnackbarResult.ActionPerformed) {
- navController.navigate("downloads") {
- launchSingleTop = true
- }
+ openDownloads()
}
}
}
diff --git a/app/src/main/java/io/github/aedev/flow/ui/DownloadsRoutes.kt b/app/src/main/java/io/github/aedev/flow/ui/DownloadsRoutes.kt
new file mode 100644
index 0000000000..aea45b858a
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/ui/DownloadsRoutes.kt
@@ -0,0 +1,57 @@
+package io.github.aedev.flow.ui
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.res.stringResource
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.composable
+import io.github.aedev.flow.R
+import io.github.aedev.flow.player.GlobalPlayerState
+import io.github.aedev.flow.ui.components.layout.navigation.DOWNLOADS_ROUTE
+import io.github.aedev.flow.ui.components.layout.navigation.DOWNLOADS_TAB_ROUTE
+import io.github.aedev.flow.ui.components.layout.navigation.MediaNavigator
+import io.github.aedev.flow.ui.screens.library.DownloadsScreen
+import io.github.aedev.flow.ui.screens.music.sharedMusicPlayerViewModel
+import io.github.aedev.flow.ui.screens.player.VideoPlayerViewModel
+
+/** The downloads page, opened over a screen with a back arrow, and as the Downloads tab without one. */
+internal fun NavGraphBuilder.downloadsRoutes(
+ navController: NavHostController,
+ mediaNavigator: MediaNavigator,
+ currentRoute: MutableState,
+ playerViewModel: VideoPlayerViewModel,
+ onMusicStarted: () -> Unit,
+) {
+ listOf(DOWNLOADS_ROUTE, DOWNLOADS_TAB_ROUTE).forEach { route ->
+ composable(route) {
+ currentRoute.value = route
+ val musicPlayerViewModel = sharedMusicPlayerViewModel()
+ val downloadsTitle = stringResource(R.string.library_downloads_label)
+ DownloadsScreen(
+ onBackClick = if (route == DOWNLOADS_ROUTE) ({ navController.popBackStack() }) else null,
+ onVideoClick = { videos, index ->
+ val videoList = videos.map { it.video }
+ playerViewModel.playPlaylist(videoList, index, downloadsTitle)
+ GlobalPlayerState.setCurrentVideo(videoList[index])
+ },
+ onMusicClick = { tracks, index ->
+ val musicTracks = tracks.map { it.track }
+ musicPlayerViewModel.loadAndPlayTrack(musicTracks[index], musicTracks, downloadsTitle)
+ onMusicStarted()
+ },
+ onOpenCollection = { summary ->
+ if (summary.collection.kind.isMusic) {
+ mediaNavigator.openMusicPlaylist(summary.collection.id)
+ } else {
+ navController.navigate("playlist/${summary.collection.id}")
+ }
+ },
+ onHomeClick = {
+ navController.navigate("home") {
+ popUpTo("home") { inclusive = true }
+ }
+ },
+ )
+ }
+ }
+}
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 377ec97cf4..f7a8d5af4e 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
@@ -37,6 +37,7 @@ import io.github.aedev.flow.ui.components.donation.DonationPromptHost
import io.github.aedev.flow.ui.components.equalizer.LocalEqualizerState
import io.github.aedev.flow.ui.components.layout.FlowBottomInsets
import io.github.aedev.flow.ui.components.layout.LocalFlowBottomInsets
+import io.github.aedev.flow.ui.components.layout.LocalFlowSnackbarHostState
import io.github.aedev.flow.ui.components.layout.navigation.FlowNavigationChrome
import io.github.aedev.flow.ui.components.layout.navigation.FlowNavigationDefaults
import io.github.aedev.flow.ui.components.layout.navigation.NavigationVisibility
@@ -83,6 +84,8 @@ fun FlowApp(
onDeeplinkConsumed: () -> Unit = {},
pendingRoute: String? = null,
onPendingRouteConsumed: () -> Unit = {},
+ downloadVideoId: String? = null,
+ onDownloadConsumed: () -> Unit = {},
onStartDestinationKnown: () -> Unit = {},
) {
val context = LocalContext.current
@@ -103,6 +106,7 @@ fun FlowApp(
val isMusicNavigationEnabled by preferences.musicNavigationEnabled.collectAsState(initial = true)
val isSearchNavigationEnabled by preferences.searchNavigationEnabled.collectAsState(initial = false)
val isCategoriesNavigationEnabled by preferences.categoriesNavigationEnabled.collectAsState(initial = false)
+ val isDownloadsNavigationEnabled by preferences.downloadsNavigationEnabled.collectAsState(initial = false)
val disableShortsPlayer by preferences.effectiveDisableShortsPlayer.collectAsState(initial = false)
val musicPlayerBackgroundStyle by preferences.musicPlayerBackgroundStyle.collectAsState(
initial = io.github.aedev.flow.data.local.MusicPlayerBackgroundStyle.BLUR_GRADIENT,
@@ -122,10 +126,14 @@ fun FlowApp(
music = isMusicNavigationEnabled,
search = isSearchNavigationEnabled,
categories = isCategoriesNavigationEnabled,
+ downloads = isDownloadsNavigationEnabled,
)
val navigationTabs = remember(navTabOrder, navigationVisibility) { visibleFlowTabs(navTabOrder, navigationVisibility) }
val defaultTab = resolveDefaultFlowTab(defaultNavTabIndex, navTabOrder, navigationVisibility)
val defaultStartRoute = defaultTab.route
+ val currentNavigationTabs by rememberUpdatedState(navigationTabs)
+ val currentStartRoute by rememberUpdatedState(defaultStartRoute)
+ val openDownloads = remember(navController) { { navController.openDownloads(currentNavigationTabs, currentStartRoute) } }
// Mini Player Customizations
val miniPlayerScale by preferences.miniPlayerScale.collectAsState(initial = 0.45f)
@@ -166,7 +174,8 @@ fun FlowApp(
)
HandleIncomingVideo(deeplinkVideoId, isShort, isSharedLink, navController, preferences, onDeeplinkConsumed)
- OfflineMonitor(context, navController, snackbarHostState, currentRoute)
+ HandleIncomingDownload(downloadVideoId, onDownloadConsumed)
+ OfflineMonitor(context, snackbarHostState, currentRoute, openDownloads)
val currentEntry by navController.currentBackStackEntryAsState()
val statusBarHero = remember { StatusBarHeroState() }
@@ -232,6 +241,8 @@ fun FlowApp(
pendingRoute,
navController,
defaultStartRoute,
+ // Read from the setting itself: a widget opening a cold start runs before the bar's state has loaded.
+ downloadsTab = { preferences.downloadsNavigationEnabled.first() },
onPendingRouteConsumed,
onBeforeNavigate = collapseExpandedPlayers,
)
@@ -491,6 +502,7 @@ fun FlowApp(
LocalMusicMenus provides musicMenus,
LocalEqualizerState provides equalizerViewModel.state,
LocalFlowBottomInsets provides bottomInsets,
+ LocalFlowSnackbarHostState provides snackbarHostState,
LocalStatusBarHero provides statusBarHero,
) {
NavHost(
@@ -516,6 +528,7 @@ fun FlowApp(
disableShortsPlayer = disableShortsPlayer,
defaultStartRoute = defaultStartRoute,
onMusicStarted = onMusicStarted,
+ openDownloads = openDownloads,
)
}
}
diff --git a/app/src/main/java/io/github/aedev/flow/ui/FlowNavigation.kt b/app/src/main/java/io/github/aedev/flow/ui/FlowNavigation.kt
index 60fea66c74..303c4cdea4 100644
--- a/app/src/main/java/io/github/aedev/flow/ui/FlowNavigation.kt
+++ b/app/src/main/java/io/github/aedev/flow/ui/FlowNavigation.kt
@@ -63,6 +63,7 @@ fun NavGraphBuilder.flowAppGraph(
disableShortsPlayer: Boolean = false,
defaultStartRoute: String = "home",
onMusicStarted: () -> Unit = {},
+ openDownloads: () -> Unit = {},
) {
// =============================================
// ONBOARDING (First-time user experience)
@@ -204,9 +205,7 @@ fun NavGraphBuilder.flowAppGraph(
onNavigateToSavedShorts = {
navController.navigate("savedShorts")
},
- onNavigateToDownloads = {
- navController.navigate("downloads")
- },
+ onNavigateToDownloads = openDownloads,
onNavigateToLocalMedia = {
navController.navigate("localMedia")
},
@@ -487,43 +486,6 @@ fun NavGraphBuilder.flowAppGraph(
)
}
- composable("downloads") {
- currentRoute.value = "downloads"
-
- val musicPlayerViewModel = sharedMusicPlayerViewModel()
- val downloadsTitle =
- androidx.compose.ui.res.stringResource(
- io.github.aedev.flow.R.string.library_downloads_label,
- )
-
- io.github.aedev.flow.ui.screens.library.DownloadsScreen(
- onBackClick = { navController.popBackStack() },
- onVideoClick = { videos, index ->
- val videoList = videos.map { it.video }
- playerViewModel.playPlaylist(videoList, index, downloadsTitle)
- GlobalPlayerState.setCurrentVideo(videoList[index])
- },
- onMusicClick = { tracks, index ->
- val musicTracks = tracks.map { it.track }
- val selectedTrack = musicTracks[index]
-
- musicPlayerViewModel.loadAndPlayTrack(selectedTrack, musicTracks, downloadsTitle)
- onMusicStarted()
- },
- onOpenCollection = { summary ->
- if (summary.collection.kind.isMusic) {
- mediaNavigator.openMusicPlaylist(summary.collection.id)
- } else {
- navController.navigate("playlist/${summary.collection.id}")
- }
- },
- onHomeClick = {
- navController.navigate("home") {
- popUpTo("home") { inclusive = true }
- }
- },
- )
- }
composable("notes") {
currentRoute.value = "notes"
io.github.aedev.flow.ui.screens.notes.NotesScreen(
@@ -554,6 +516,8 @@ fun NavGraphBuilder.flowAppGraph(
)
}
+ downloadsRoutes(navController, mediaNavigator, currentRoute, playerViewModel, onMusicStarted)
+
musicRoutes(navController, mediaNavigator, currentRoute, playerViewModel, defaultStartRoute, onMusicStarted)
widgetPlaybackRoutes(navController, currentRoute, defaultStartRoute, playerViewModel, onMusicStarted)
diff --git a/app/src/main/java/io/github/aedev/flow/ui/IncomingLinks.kt b/app/src/main/java/io/github/aedev/flow/ui/IncomingLinks.kt
index a2469543a8..806fba6d61 100644
--- a/app/src/main/java/io/github/aedev/flow/ui/IncomingLinks.kt
+++ b/app/src/main/java/io/github/aedev/flow/ui/IncomingLinks.kt
@@ -26,6 +26,10 @@ internal class IncomingLinks {
private val _route = mutableStateOf(null)
val route: State = _route
+ /** A video shared to Download with Flow, which opens the download menu instead of the player. */
+ private val _downloadVideoId = mutableStateOf(null)
+ val downloadVideoId: State = _downloadVideoId
+
fun openVideo(
videoId: String,
isShort: Boolean = false,
@@ -55,6 +59,21 @@ internal class IncomingLinks {
_route.value = null
}
+ fun consumeDownload() {
+ _downloadVideoId.value = null
+ }
+
+ /** A link shared to Download with Flow; false when it names no video or short. */
+ fun openDownload(text: String): Boolean {
+ _downloadVideoId.value =
+ when (val destination = parseYouTubeLink(text)?.let(::linkDestination)) {
+ is LinkDestination.Video -> destination.videoId
+ is LinkDestination.Short -> destination.videoId
+ is LinkDestination.Page, null -> return false
+ }
+ return true
+ }
+
/**
* Opens a YouTube link from another app; false when Flow has no page for it. A recreated activity
* gets its intent again with its back stack restored, so then only playback is re-requested, and
diff --git a/app/src/main/java/io/github/aedev/flow/ui/NavigationDestinations.kt b/app/src/main/java/io/github/aedev/flow/ui/NavigationDestinations.kt
index 200006c929..8c5aaf6171 100644
--- a/app/src/main/java/io/github/aedev/flow/ui/NavigationDestinations.kt
+++ b/app/src/main/java/io/github/aedev/flow/ui/NavigationDestinations.kt
@@ -3,6 +3,7 @@ package io.github.aedev.flow.ui
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import io.github.aedev.flow.data.shorts.queue.ShortsQueueSource
+import io.github.aedev.flow.ui.components.layout.navigation.DOWNLOADS_ROUTE
import io.github.aedev.flow.ui.components.layout.navigation.FlowTab
import java.net.URI
import java.net.URLEncoder
@@ -21,7 +22,24 @@ internal fun flowTabForDestination(
internal fun NavBackStackEntry.flowTab(): FlowTab? = flowTabForDestination(destination.route, arguments?.getString(SHORTS_ROUTE_ARG))
/** The tab a route handed in from outside the graph (a widget, a shortcut) names, if any. */
-internal fun flowTabForRoute(route: String): FlowTab? = FlowTab.entries.firstOrNull { it.route == route }
+internal fun flowTabForRoute(
+ route: String,
+ downloadsTab: Boolean,
+): FlowTab? =
+ FlowTab.entries.firstOrNull { it.route == route }
+ ?: FlowTab.Downloads.takeIf { route == DOWNLOADS_ROUTE && downloadsTab }
+
+/** Downloads as its tab when the bar has one, else as a page over the current screen. */
+internal fun NavController.openDownloads(
+ tabs: List,
+ startRoute: String,
+) {
+ if (FlowTab.Downloads in tabs) {
+ navigateToTab(FlowTab.Downloads, startRoute)
+ } else {
+ navigate(DOWNLOADS_ROUTE) { launchSingleTop = true }
+ }
+}
/** Set on the Subscriptions entry to open its management view on the Music tab once. */
internal const val OPEN_MUSIC_SUBSCRIPTIONS = "open_music_subscriptions"
diff --git a/app/src/main/java/io/github/aedev/flow/ui/SharedLinkSheet.kt b/app/src/main/java/io/github/aedev/flow/ui/SharedLinkSheet.kt
index 5172035f79..3fb97afad1 100644
--- a/app/src/main/java/io/github/aedev/flow/ui/SharedLinkSheet.kt
+++ b/app/src/main/java/io/github/aedev/flow/ui/SharedLinkSheet.kt
@@ -96,3 +96,17 @@ internal fun SharedLinkSheet(
viewModel = viewModel,
)
}
+
+/** Opens the download menu for a video shared to Download with Flow, as the menu's Download does. */
+@Composable
+internal fun HandleIncomingDownload(
+ videoId: String?,
+ onConsumed: () -> Unit,
+ viewModel: QuickActionsViewModel = sharedQuickActionsViewModel(),
+) {
+ LaunchedEffect(videoId) {
+ if (videoId == null) return@LaunchedEffect
+ onConsumed()
+ viewModel.requestLinkedDownload(linkedVideo(videoId))
+ }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/layout/FlowSnackbarHost.kt b/app/src/main/java/io/github/aedev/flow/ui/components/layout/FlowSnackbarHost.kt
new file mode 100644
index 0000000000..b6bc39e40e
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/ui/components/layout/FlowSnackbarHost.kt
@@ -0,0 +1,10 @@
+package io.github.aedev.flow.ui.components.layout
+
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.staticCompositionLocalOf
+
+/**
+ * The app shell's snackbar, drawn above the mini player and the navigation bar, where its action
+ * can be tapped; a page's own Scaffold snackbar sits under them. Null outside the shell.
+ */
+val LocalFlowSnackbarHostState = staticCompositionLocalOf { null }
diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/layout/navigation/FlowTab.kt b/app/src/main/java/io/github/aedev/flow/ui/components/layout/navigation/FlowTab.kt
index bbe8baa676..d72518f19c 100644
--- a/app/src/main/java/io/github/aedev/flow/ui/components/layout/navigation/FlowTab.kt
+++ b/app/src/main/java/io/github/aedev/flow/ui/components/layout/navigation/FlowTab.kt
@@ -2,12 +2,14 @@ package io.github.aedev.flow.ui.components.layout.navigation
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Subscriptions
import androidx.compose.material.icons.filled.VideoLibrary
+import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.MusicNote
@@ -20,6 +22,12 @@ import androidx.compose.ui.res.vectorResource
import io.github.aedev.flow.R
import io.github.aedev.flow.data.local.DEFAULT_NAV_TAB_ORDER
+/** The downloads page other screens open over themselves, with a back arrow. */
+const val DOWNLOADS_ROUTE = "downloads"
+
+/** The Downloads tab's own copy of that page, so the page opened from Library never shows the bar. */
+const val DOWNLOADS_TAB_ROUTE = "downloadsTab"
+
/**
* A root destination of the app's primary navigation.
*
@@ -37,6 +45,7 @@ enum class FlowTab(
Library(4, "library", R.string.nav_library),
Search(5, "search", R.string.nav_search),
Explore(6, "categories", R.string.nav_explore),
+ Downloads(7, DOWNLOADS_TAB_ROUTE, R.string.nav_downloads),
;
companion object {
@@ -54,6 +63,7 @@ fun FlowTab.icon(selected: Boolean): ImageVector =
FlowTab.Library -> if (selected) Icons.Filled.VideoLibrary else Icons.Outlined.VideoLibrary
FlowTab.Search -> if (selected) Icons.Filled.Search else Icons.Outlined.Search
FlowTab.Explore -> if (selected) Icons.Filled.Explore else Icons.Outlined.Explore
+ FlowTab.Downloads -> if (selected) Icons.Filled.Download else Icons.Outlined.Download
}
data class NavigationVisibility(
@@ -62,6 +72,7 @@ data class NavigationVisibility(
val music: Boolean = true,
val search: Boolean = false,
val categories: Boolean = false,
+ val downloads: Boolean = false,
)
/**
@@ -81,6 +92,7 @@ fun visibleFlowTabs(
add(FlowTab.Library)
if (visibility.search) add(FlowTab.Search)
if (visibility.categories) add(FlowTab.Explore)
+ if (visibility.downloads) add(FlowTab.Downloads)
}
return (order + DEFAULT_NAV_TAB_ORDER)
.distinct()
diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/library/DownloadSwipeToDelete.kt b/app/src/main/java/io/github/aedev/flow/ui/components/library/DownloadSwipeToDelete.kt
new file mode 100644
index 0000000000..cb791975ec
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/ui/components/library/DownloadSwipeToDelete.kt
@@ -0,0 +1,79 @@
+package io.github.aedev.flow.ui.components.library
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SwipeToDismissBox
+import androidx.compose.material3.SwipeToDismissBoxDefaults
+import androidx.compose.material3.SwipeToDismissBoxState
+import androidx.compose.material3.SwipeToDismissBoxValue
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.CustomAccessibilityAction
+import androidx.compose.ui.semantics.customActions
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import io.github.aedev.flow.R
+
+private val IconPadding = 24.dp
+
+/**
+ * A finished download that deletes when swiped toward its end edge. TalkBack gets Delete as a
+ * custom action, since the swipe is not reachable from a screen reader. Off in selection mode.
+ */
+@Composable
+internal fun DownloadSwipeToDelete(
+ enabled: Boolean,
+ onDelete: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit,
+) {
+ // Not rememberSaveable: the grid restores saved state by key, so a download brought back by
+ // Undo would return already swiped away and delete itself again.
+ val positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold
+ val state = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, positionalThreshold) }
+ val deleteLabel = stringResource(R.string.delete)
+ SwipeToDismissBox(
+ state = state,
+ modifier =
+ modifier.clip(MaterialTheme.shapes.large).semantics {
+ if (enabled) {
+ customActions =
+ listOf(
+ CustomAccessibilityAction(deleteLabel) {
+ onDelete()
+ true
+ },
+ )
+ }
+ },
+ enableDismissFromStartToEnd = false,
+ gesturesEnabled = enabled,
+ onDismiss = { value -> if (value == SwipeToDismissBoxValue.EndToStart) onDelete() },
+ backgroundContent = {
+ if (state.dismissDirection != SwipeToDismissBoxValue.EndToStart) return@SwipeToDismissBox
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.errorContainer)
+ .padding(horizontal = IconPadding),
+ contentAlignment = Alignment.CenterEnd,
+ ) {
+ Icon(Icons.Outlined.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.onErrorContainer)
+ }
+ },
+ ) {
+ // The rows draw no background of their own, so the page colour keeps the red one hidden under them.
+ Box(Modifier.background(MaterialTheme.colorScheme.background)) { content() }
+ }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/library/DownloadsLists.kt b/app/src/main/java/io/github/aedev/flow/ui/components/library/DownloadsLists.kt
index 7f178bdf51..7f3638d3a2 100644
--- a/app/src/main/java/io/github/aedev/flow/ui/components/library/DownloadsLists.kt
+++ b/app/src/main/java/io/github/aedev/flow/ui/components/library/DownloadsLists.kt
@@ -94,6 +94,7 @@ internal fun VideosDownloadsList(
onRefresh: () -> Unit,
onVideoClick: (List, Int) -> Unit,
onDelete: (id: String, title: String) -> Unit,
+ onSwipeDelete: (id: String) -> Unit,
onHomeClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -134,16 +135,21 @@ internal fun VideosDownloadsList(
span = { index, _ -> GridItemSpan(if (index in partialRows) maxLineSpan else 1) },
) { index, video ->
val asCard = grid.isGrid && index !in partialRows
- VideoDownloadItem(
- video = video,
- asCard = asCard,
- selectionMode = selection.active,
- selected = video.video.id in selection.ids,
- onClick = { if (selection.active) selection.onToggle(video.video.id) else onVideoClick(videos, index) },
- onDeleteClick = { onDelete(video.video.id, video.video.title) },
- rowThumbnailWidth = grid.rowThumbnailWidth,
+ DownloadSwipeToDelete(
+ enabled = !selection.active,
+ onDelete = { onSwipeDelete(video.video.id) },
modifier = animateMediaGridItem().padding(bottom = if (asCard) GridSpacing else 0.dp),
- )
+ ) {
+ VideoDownloadItem(
+ video = video,
+ asCard = asCard,
+ selectionMode = selection.active,
+ selected = video.video.id in selection.ids,
+ onClick = { if (selection.active) selection.onToggle(video.video.id) else onVideoClick(videos, index) },
+ onDeleteClick = { onDelete(video.video.id, video.video.title) },
+ rowThumbnailWidth = grid.rowThumbnailWidth,
+ )
+ }
}
}
}
@@ -167,6 +173,7 @@ internal fun MusicDownloadsList(
isRefreshing: Boolean,
onRefresh: () -> Unit,
onMusicClick: (List, Int) -> Unit,
+ onSwipeDelete: (id: String) -> Unit,
onHomeClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -197,13 +204,18 @@ internal fun MusicDownloadsList(
fullWidth("no_results") { NoResults(query) }
}
itemsIndexed(tracks, key = { _, track -> track.track.videoId }, contentType = { _, _ -> "track" }) { index, track ->
- MusicDownloadRow(
- downloadedTrack = track,
- selectionMode = selection.active,
- selected = track.track.videoId in selection.ids,
- onClick = { if (selection.active) selection.onToggle(track.track.videoId) else onMusicClick(tracks, index) },
+ DownloadSwipeToDelete(
+ enabled = !selection.active,
+ onDelete = { onSwipeDelete(track.track.videoId) },
modifier = animateMediaGridItem(),
- )
+ ) {
+ MusicDownloadRow(
+ downloadedTrack = track,
+ selectionMode = selection.active,
+ selected = track.track.videoId in selection.ids,
+ onClick = { if (selection.active) selection.onToggle(track.track.videoId) else onMusicClick(tracks, index) },
+ )
+ }
}
}
}
diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsViewModel.kt
index d7c53642ab..f274af495c 100644
--- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsViewModel.kt
+++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsViewModel.kt
@@ -236,6 +236,11 @@ class QuickActionsViewModel
emit(R.string.added_to_queue_toast)
}
+ /** A video shared to Download with Flow: its title and channel first, which name the file. */
+ fun requestLinkedDownload(video: Video) {
+ viewModelScope.launch { requestDownload(loadLinkedVideo(video) ?: video) }
+ }
+
/** Loads [video]'s formats, then opens the download dialog; a second tap while loading is ignored. */
fun requestDownload(video: Video) {
if (downloadJob?.isActive == true) return
diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/library/DownloadsDeletion.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/library/DownloadsDeletion.kt
new file mode 100644
index 0000000000..a7a2eceab3
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/ui/screens/library/DownloadsDeletion.kt
@@ -0,0 +1,81 @@
+package io.github.aedev.flow.ui.screens.library
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import io.github.aedev.flow.R
+import io.github.aedev.flow.ui.components.shared.FlowAlertDialog
+import kotlinx.coroutines.flow.collectLatest
+
+/**
+ * The Undo snackbar for each delete the Downloads screen makes. The files go when it closes without
+ * Undo, or when the screen leaves; a newer delete replaces it after the ViewModel commits the older one.
+ */
+@Composable
+internal fun DownloadsUndoEffect(
+ viewModel: DownloadsViewModel,
+ snackbarHostState: SnackbarHostState,
+) {
+ val context = LocalContext.current
+ LaunchedEffect(viewModel) {
+ viewModel.deleted.collectLatest { count ->
+ snackbarHostState.currentSnackbarData?.dismiss()
+ val result =
+ snackbarHostState.showSnackbar(
+ message =
+ if (count == 1) {
+ context.getString(R.string.download_deleted)
+ } else {
+ context.resources.getQuantityString(R.plurals.downloads_deleted, count, count)
+ },
+ actionLabel = context.getString(R.string.undo),
+ duration = SnackbarDuration.Long,
+ )
+ if (result == SnackbarResult.ActionPerformed) viewModel.undoDelete() else viewModel.commitDeletes()
+ }
+ }
+ DisposableEffect(viewModel) { onDispose { viewModel.commitDeletes() } }
+}
+
+@Composable
+internal fun DeleteDownloadsDialog(
+ deletion: PendingDeletion,
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit,
+) {
+ FlowAlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.delete_download_dialog_title)) },
+ text = {
+ Text(
+ if (deletion.title != null) {
+ stringResource(R.string.delete_download_dialog_text, deletion.title)
+ } else {
+ pluralStringResource(R.plurals.delete_downloads_dialog_text, deletion.ids.size, deletion.ids.size)
+ },
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = onConfirm) {
+ Text(text = stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error)
+ }
+ },
+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
+ )
+}
+
+/** What a confirmed delete removes: one download named by [title], or a selection; a queued one is cancelled with no Undo. */
+internal data class PendingDeletion(
+ val ids: Set,
+ val title: String?,
+ val undoable: Boolean = true,
+)
diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/library/DownloadsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/library/DownloadsScreen.kt
index 26b17df531..a9b41fee9d 100644
--- a/app/src/main/java/io/github/aedev/flow/ui/screens/library/DownloadsScreen.kt
+++ b/app/src/main/java/io/github/aedev/flow/ui/screens/library/DownloadsScreen.kt
@@ -22,11 +22,14 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -51,6 +54,7 @@ import io.github.aedev.flow.data.local.dao.DownloadCollectionSummary
import io.github.aedev.flow.data.music.DownloadedTrack
import io.github.aedev.flow.data.video.DownloadedVideo
import io.github.aedev.flow.data.video.storage.StorageAccess
+import io.github.aedev.flow.ui.components.layout.LocalFlowSnackbarHostState
import io.github.aedev.flow.ui.components.layout.topbar.FlowTopBar
import io.github.aedev.flow.ui.components.library.ActiveDownloadActions
import io.github.aedev.flow.ui.components.library.DownloadCollectionsShelf
@@ -69,10 +73,11 @@ import io.github.aedev.flow.ui.components.shared.FlowSortChip
import io.github.aedev.flow.ui.components.shared.MediaKind
import io.github.aedev.flow.ui.components.shared.dismissKeyboardOnPress
import io.github.aedev.flow.ui.components.shared.flowGridColumns
+import kotlinx.coroutines.flow.collectLatest
@Composable
fun DownloadsScreen(
- onBackClick: () -> Unit,
+ onBackClick: (() -> Unit)?,
onVideoClick: (videos: List, startIndex: Int) -> Unit,
onMusicClick: (List, Int) -> Unit,
onOpenCollection: (DownloadCollectionSummary) -> Unit,
@@ -81,8 +86,11 @@ fun DownloadsScreen(
viewModel: DownloadsViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val showStorageCard by viewModel.showStorageCard.collectAsStateWithLifecycle()
+ val confirmDelete by viewModel.confirmDelete.collectAsStateWithLifecycle()
val context = LocalContext.current
- val snackbarHostState = remember { SnackbarHostState() }
+ val shellSnackbar = LocalFlowSnackbarHostState.current
+ val snackbarHostState = shellSnackbar ?: remember { SnackbarHostState() }
var access by remember { mutableStateOf(StorageAccess.read(context)) }
var recoveryDismissed by rememberSaveable { mutableStateOf(false) }
LifecycleResumeEffect(Unit) {
@@ -106,6 +114,7 @@ fun DownloadsScreen(
}
}
LaunchedEffect(viewModel) { viewModel.recovered.collect { snackbarHostState.showSnackbar(recoveredMessage(it)) } }
+ DownloadsUndoEffect(viewModel, snackbarHostState)
val deletePrompt = rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { }
LaunchedEffect(viewModel) { viewModel.deleteConsent.collect { deletePrompt.launch(IntentSenderRequest.Builder(it).build()) } }
var selectedKind by rememberSaveable { mutableStateOf(MediaKind.Videos) }
@@ -120,6 +129,18 @@ fun DownloadsScreen(
selectionMode = false
selectedIds = emptySet()
}
+ val delete = { deletion: PendingDeletion ->
+ if (confirmDelete) {
+ pendingDeletion = deletion
+ } else {
+ viewModel.deleteDownloads(deletion.ids)
+ exitSelection()
+ }
+ }
+ val swipeDelete = { id: String ->
+ haptic.performHapticFeedback(HapticFeedbackType.Confirm)
+ viewModel.deleteDownloads(setOf(id))
+ }
val shownIds =
if (selectedKind ==
MediaKind.Videos
@@ -141,7 +162,7 @@ fun DownloadsScreen(
onPause = viewModel::pauseVideoDownload,
onResume = viewModel::resumeVideoDownload,
onRetry = viewModel::retryVideoDownload,
- onCancel = { id, title -> pendingDeletion = PendingDeletion(setOf(id), title) },
+ onCancel = { id, title -> pendingDeletion = PendingDeletion(setOf(id), title, undoable = false) },
onCancelAll = { removeIncompleteOf = selectedKind },
)
val sortChip: @Composable () -> Unit = {
@@ -157,7 +178,9 @@ fun DownloadsScreen(
val sortInHeader = libraryHeaderIsOneRow()
val listHeader: @Composable () -> Unit = {
Column {
- DownloadsStorageCard(uiState.storage.videoBytes, uiState.storage.musicBytes, uiState.storage.freeBytes)
+ if (showStorageCard) {
+ DownloadsStorageCard(uiState.storage.videoBytes, uiState.storage.musicBytes, uiState.storage.freeBytes)
+ }
if (!sortInHeader) {
Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.weight(1f))
@@ -193,7 +216,7 @@ fun DownloadsScreen(
)
},
containerColor = MaterialTheme.colorScheme.background,
- snackbarHost = { SnackbarHost(snackbarHostState) },
+ snackbarHost = { if (shellSnackbar == null) SnackbarHost(snackbarHostState) },
) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) {
Column(Modifier.fillMaxSize()) {
@@ -245,7 +268,8 @@ fun DownloadsScreen(
isRefreshing = uiState.isScanning,
onRefresh = viewModel::rescan,
onVideoClick = onVideoClick,
- onDelete = { id, title -> pendingDeletion = PendingDeletion(setOf(id), title) },
+ onDelete = { id, title -> delete(PendingDeletion(setOf(id), title)) },
+ onSwipeDelete = swipeDelete,
onHomeClick = onHomeClick,
)
}
@@ -267,6 +291,7 @@ fun DownloadsScreen(
isRefreshing = uiState.isScanning,
onRefresh = viewModel::rescan,
onMusicClick = onMusicClick,
+ onSwipeDelete = swipeDelete,
onHomeClick = onHomeClick,
)
}
@@ -279,7 +304,7 @@ fun DownloadsScreen(
actions =
listOf(
FlowSelectionAction(Icons.Outlined.Delete, stringResource(R.string.action_delete)) {
- pendingDeletion = PendingDeletion(selectedIds, title = null)
+ delete(PendingDeletion(selectedIds, title = null))
},
),
modifier = Modifier.align(Alignment.BottomCenter),
@@ -293,7 +318,7 @@ fun DownloadsScreen(
onDismiss = { pendingDeletion = null },
onConfirm = {
haptic.performHapticFeedback(HapticFeedbackType.Confirm)
- viewModel.deleteDownloads(deletion.ids)
+ if (deletion.undoable) viewModel.deleteDownloads(deletion.ids) else viewModel.cancelDownloads(deletion.ids)
pendingDeletion = null
exitSelection()
},
@@ -332,37 +357,4 @@ fun DownloadsScreen(
}
}
-@Composable
-private fun DeleteDownloadsDialog(
- deletion: PendingDeletion,
- onDismiss: () -> Unit,
- onConfirm: () -> Unit,
-) {
- FlowAlertDialog(
- onDismissRequest = onDismiss,
- title = { Text(stringResource(R.string.delete_download_dialog_title)) },
- text = {
- Text(
- if (deletion.title != null) {
- stringResource(R.string.delete_download_dialog_text, deletion.title)
- } else {
- pluralStringResource(R.plurals.delete_downloads_dialog_text, deletion.ids.size, deletion.ids.size)
- },
- )
- },
- confirmButton = {
- TextButton(onClick = onConfirm) {
- Text(text = stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error)
- }
- },
- dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
- )
-}
-
private val SelectionSaver = listSaver, String>(save = { it.toList() }, restore = { it.toSet() })
-
-/** What a confirmed delete removes: one download named by [title], or a selection. */
-private data class PendingDeletion(
- val ids: Set,
- val title: String?,
-)
diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/library/DownloadsViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/library/DownloadsViewModel.kt
index 761713b364..875cbea080 100644
--- a/app/src/main/java/io/github/aedev/flow/ui/screens/library/DownloadsViewModel.kt
+++ b/app/src/main/java/io/github/aedev/flow/ui/screens/library/DownloadsViewModel.kt
@@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
+import io.github.aedev.flow.data.local.PlayerPreferences
import io.github.aedev.flow.data.local.dao.DownloadCollectionSummary
import io.github.aedev.flow.data.local.entity.DownloadFileType
import io.github.aedev.flow.data.local.entity.DownloadItemStatus
@@ -19,12 +20,15 @@ import io.github.aedev.flow.data.video.VideoDownloadManager
import io.github.aedev.flow.data.video.downloader.collection.DownloadedCollections
import io.github.aedev.flow.data.video.downloader.work.DownloadController
import io.github.aedev.flow.data.video.storage.deleteRequestFor
+import io.github.aedev.flow.di.ApplicationScope
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -32,6 +36,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -49,12 +54,27 @@ class DownloadsViewModel
private val musicDownloadManager: MusicDownloadManager,
private val downloadController: DownloadController,
private val collections: DownloadedCollections,
+ preferences: PlayerPreferences,
+ @param:ApplicationScope private val applicationScope: CoroutineScope,
) : ViewModel() {
private val _uiState = MutableStateFlow(DownloadsUiState())
val uiState: StateFlow = _uiState.asStateFlow()
// Hidden from the lists while their deletion runs; each id drops out once the row is gone.
private val pendingDeleteIds = MutableStateFlow>(emptySet())
+
+ // Off the lists but still on disk while the Undo snackbar shows, each id marked true for a song.
+ private val awaitingUndo = MutableStateFlow