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>(emptyMap()) + private val hiddenIds = combine(pendingDeleteIds, awaitingUndo) { pending, undoable -> pending + undoable.keys } + private val _deleted = MutableSharedFlow(extraBufferCapacity = 1) + + /** How many downloads a delete just took off the lists, for the screen to offer Undo. */ + val deleted: SharedFlow = _deleted.asSharedFlow() + + val showStorageCard: StateFlow = + preferences.downloadsStorageCard.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true) + val confirmDelete: StateFlow = + preferences.downloadsConfirmDelete.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true) private val allVideos = MutableStateFlow>(emptyList()) private val allTracks = MutableStateFlow>(emptyList()) private val query = MutableStateFlow("") @@ -83,12 +103,12 @@ class DownloadsViewModel private fun observeDownloads() { viewModelScope.launch { - combine(refreshTick.flatMapLatest { musicDownloadManager.downloadedTracks }, pendingDeleteIds) { tracks, pending -> + combine(refreshTick.flatMapLatest { musicDownloadManager.downloadedTracks }, hiddenIds) { tracks, pending -> tracks.filter { it.track.videoId !in pending } }.collect { allTracks.value = it } } viewModelScope.launch { - combine(refreshTick.flatMapLatest { videoDownloadManager.downloadedVideos }, pendingDeleteIds) { videos, pending -> + combine(refreshTick.flatMapLatest { videoDownloadManager.downloadedVideos }, hiddenIds) { videos, pending -> videos.filter { it.video.id !in pending } }.collect { allVideos.value = it } } @@ -170,10 +190,44 @@ class DownloadsViewModel }.getOrDefault(0L) } + /** + * Takes [ids] off the lists at once; their files go when [commitDeletes] runs, unless + * [undoDelete] comes first. A delete still waiting on Undo is committed before this one. + */ + fun deleteDownloads(ids: Set) { + if (ids.isEmpty()) return + commitDeletes() + val musicIds = allTracks.value.mapTo(HashSet()) { it.track.videoId } + awaitingUndo.value = ids.associateWith { it in musicIds } + _deleted.tryEmit(ids.size) + } + + fun undoDelete() { + awaitingUndo.value = emptyMap() + } + + /** Deletes what waits on Undo: when its snackbar goes, when the screen goes, or before the next delete. */ + fun commitDeletes() { + val waiting = awaitingUndo.value + if (waiting.isEmpty()) return + pendingDeleteIds.update { it + waiting.keys } + awaitingUndo.value = emptyMap() + waiting.forEach { (id, isMusic) -> if (isMusic) deleteMusicDownload(id) else deleteVideoDownload(id) } + } + + /** Cancels downloads still in the queue, with no Undo: the transfer stops at once. */ + fun cancelDownloads(ids: Set) { + pendingDeleteIds.update { it + ids } + ids.forEach(::deleteVideoDownload) + } + + override fun onCleared() { + commitDeletes() + } + /** A finished download is deleted here; one still in the queue is cancelled, which stops it first. */ - fun deleteVideoDownload(videoId: String) { - pendingDeleteIds.update { it + videoId } - viewModelScope.launch { + private fun deleteVideoDownload(videoId: String) { + applicationScope.launch { val download = videoDownloadManager.getDownloadWithItems(videoId) if (download?.overallStatus == DownloadItemStatus.COMPLETED) { videoDownloadManager.deleteDownload(videoId) @@ -183,14 +237,8 @@ class DownloadsViewModel } } - fun deleteMusicDownload(videoId: String) { - pendingDeleteIds.update { it + videoId } - viewModelScope.launch { musicDownloadManager.deleteDownload(videoId) } - } - - fun deleteDownloads(ids: Set) { - val musicIds = allTracks.value.mapTo(HashSet()) { it.track.videoId } - ids.forEach { id -> if (id in musicIds) deleteMusicDownload(id) else deleteVideoDownload(id) } + private fun deleteMusicDownload(videoId: String) { + applicationScope.launch { musicDownloadManager.deleteDownload(videoId) } } fun pauseVideoDownload(videoId: String) { diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/AppearanceViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/AppearanceViewModel.kt index 50a49ccb03..4b0acec573 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/AppearanceViewModel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/AppearanceViewModel.kt @@ -65,7 +65,8 @@ class AppearanceViewModel preferences.categoriesNavigationEnabled, ) { home, shorts, music, search, categories -> NavigationVisibility(home = home, shorts = shorts, music = music, search = search, categories = categories) - }.asState(NavigationVisibility()) + }.combine(preferences.downloadsNavigationEnabled) { visibility, downloads -> visibility.copy(downloads = downloads) } + .asState(NavigationVisibility()) val hideNavOnScroll = preferences.bottomNavHideOnScroll.asState(true) val navTabOrder = preferences.navTabOrder.asState(DEFAULT_NAV_TAB_ORDER) val defaultNavTabIndex = preferences.defaultNavTabIndex.asState(0) @@ -115,6 +116,8 @@ class AppearanceViewModel fun setExploreTab(enabled: Boolean) = write { preferences.setCategoriesNavigationEnabled(enabled) } + fun setDownloadsTab(enabled: Boolean) = write { preferences.setDownloadsNavigationEnabled(enabled) } + fun setHideNavOnScroll(enabled: Boolean) = write { preferences.setBottomNavHideOnScroll(enabled) } fun setNavTabOrder(order: List) = write { preferences.setNavTabOrder(order) } diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/NavigationBarScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/NavigationBarScreen.kt index b518859c22..b001f1236d 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/NavigationBarScreen.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/NavigationBarScreen.kt @@ -3,6 +3,7 @@ package io.github.aedev.flow.ui.screens.settings.appearance import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons +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 @@ -60,6 +61,7 @@ internal fun NavigationBarScreen( switch(NavigationBarIndex.music, visibility.music, viewModel::setMusicTab, icon = Icons.Outlined.MusicNote) switch(NavigationBarIndex.search, visibility.search, viewModel::setSearchTab, icon = Icons.Outlined.Search) switch(NavigationBarIndex.explore, visibility.categories, viewModel::setExploreTab, icon = Icons.Outlined.Explore) + switch(NavigationBarIndex.downloads, visibility.downloads, viewModel::setDownloadsTab, icon = Icons.Outlined.Download) switch(NavigationBarIndex.hideOnScroll, hideOnScroll, viewModel::setHideNavOnScroll, icon = Icons.Outlined.UnfoldLess) } group( diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/downloads/DownloadSettingsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/downloads/DownloadSettingsScreen.kt index c64df4d603..3502f76eb3 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/downloads/DownloadSettingsScreen.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/downloads/DownloadSettingsScreen.kt @@ -200,6 +200,8 @@ internal fun DownloadSettingsScreen( icon = Icons.Outlined.PermMedia, onClick = { onNavigate(SettingsTarget(SettingsDestination.LOCAL_MEDIA)) }, ) + switch(DownloadsIndex.storageCard, viewModel.storageCard, viewModel::setStorageCard) + switch(DownloadsIndex.confirmDelete, viewModel.confirmDelete, viewModel::setConfirmDelete) info(DownloadsIndex.retag, value = retagValue) nav( DownloadsIndex.findEarlier, diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/downloads/DownloadSettingsViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/downloads/DownloadSettingsViewModel.kt index e7a77a8da5..3d2a29f0af 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/downloads/DownloadSettingsViewModel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/downloads/DownloadSettingsViewModel.kt @@ -93,6 +93,8 @@ class DownloadSettingsViewModel val musicQuality = preferences.musicDownloadQuality.asState(MusicAudioQuality.HIGH) val menuStyle = preferences.downloadDialogStyle.asState(DownloadDialogStyle.FULL) val askEveryTime = preferences.downloadAskEveryTime.asState(true) + val storageCard = preferences.downloadsStorageCard.asState(true) + val confirmDelete = preferences.downloadsConfirmDelete.asState(true) val wifiOnly = preferences.downloadOverWifiOnly.asState(false) val autoDownloadLikes = preferences.autoDownloadLikedMusic.asState(false) val autoDownloadOpened = preferences.autoDownloadOpenedVideos.asState(AutoDownloadMode.OFF) @@ -166,6 +168,10 @@ class DownloadSettingsViewModel fun setAskEveryTime(value: Boolean) = write { preferences.setDownloadAskEveryTime(value) } + fun setStorageCard(value: Boolean) = write { preferences.setDownloadsStorageCard(value) } + + fun setConfirmDelete(value: Boolean) = write { preferences.setDownloadsConfirmDelete(value) } + // Queued downloads wait on the network the setting allowed when they were queued, so it is re-applied. fun setAutoDownloadLikes(value: Boolean) = write { preferences.setAutoDownloadLikedMusic(value) } diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/AppearanceIndex.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/AppearanceIndex.kt index 44cdbac87c..f9faae9893 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/AppearanceIndex.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/AppearanceIndex.kt @@ -145,6 +145,7 @@ internal object NavigationBarIndex { val music = tab("music", R.string.settings_music_nav_tab_title, R.string.settings_music_nav_tab_subtitle) val search = tab("search", R.string.settings_search_nav_tab_title, R.string.settings_search_nav_tab_subtitle) val explore = tab("explore", R.string.settings_categories_nav_tab_title, R.string.settings_categories_nav_tab_subtitle) + val downloads = tab("downloads", R.string.settings_downloads_nav_tab_title, R.string.settings_downloads_nav_tab_subtitle) val hideOnScroll = SettingEntry( key = "navigation_bar.hide_on_scroll", @@ -163,7 +164,7 @@ internal object NavigationBarIndex { destination = page, ) - val all = listOf(home, shorts, music, search, explore, hideOnScroll, order) + val all = listOf(home, shorts, music, search, explore, downloads, hideOnScroll, order) } internal object ThemeIndex { diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/DownloadsIndex.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/DownloadsIndex.kt index 5d3a8600e1..a1627c97cc 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/DownloadsIndex.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/DownloadsIndex.kt @@ -90,6 +90,20 @@ internal object DownloadsIndex { R.string.settings_section_download_defaults, summary = R.string.download_subtitle_file_subtitle, ) + val storageCard = + entry( + "storage_card", + R.string.downloads_storage_card_title, + R.string.local_section_library, + summary = R.string.downloads_storage_card_summary, + ) + val confirmDelete = + entry( + "confirm_delete", + R.string.downloads_confirm_delete_title, + R.string.local_section_library, + summary = R.string.downloads_confirm_delete_summary, + ) val retag = entry("retag", R.string.download_retag_title, R.string.local_section_library) val findEarlier = entry("find_earlier", R.string.downloads_find_earlier_title, R.string.local_section_library) val concurrentDownloads = @@ -141,6 +155,8 @@ internal object DownloadsIndex { autoDownloadOpened, wifiOnly, subtitleFile, + storageCard, + confirmDelete, retag, findEarlier, concurrentDownloads, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 440b1bb886..5ef019727c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1224,6 +1224,9 @@ Explore tab in navigation Add a Trending & Categories tab to the navigation bar Explore + Downloads + Downloads tab in navigation + Add a Downloads tab to the navigation bar More Expand navigation Collapse navigation @@ -2473,6 +2476,12 @@ Editable title, dropdowns and thread count Ask every time When off, Download uses your download quality + Download with Flow + Only videos can be downloaded from a shared link + Storage summary + Show the space used and free at the top of Downloads + Ask before deleting + Swipe to delete skips this and can be undone Download %1$s %1$s · %2$s free Change @@ -3495,6 +3504,10 @@ Delete %1$d download from this device? Delete %1$d downloads from this device? + + %1$d download deleted + %1$d downloads deleted + Newest Oldest Largest diff --git a/app/src/test/java/io/github/aedev/flow/platform/LauncherAliasManifestTest.kt b/app/src/test/java/io/github/aedev/flow/platform/LauncherAliasManifestTest.kt index a519f80b6d..dc67de97cf 100644 --- a/app/src/test/java/io/github/aedev/flow/platform/LauncherAliasManifestTest.kt +++ b/app/src/test/java/io/github/aedev/flow/platform/LauncherAliasManifestTest.kt @@ -22,6 +22,13 @@ class LauncherAliasManifestTest { .parse(File("src/main/AndroidManifest.xml")) .getElementsByTagName("activity-alias") .let { nodes -> (0 until nodes.length).map { nodes.item(it) as Element } } + .filter { it.isLauncherEntry() } + + // Other aliases, such as the share menu's Download with Flow, are not app icons. + private fun Element.isLauncherEntry(): Boolean { + val categories = getElementsByTagName("category") + return (0 until categories.length).any { (categories.item(it) as Element).android("name") == LAUNCHER } + } private fun Element.android(name: String) = getAttributeNS(androidNs, name) @@ -46,4 +53,8 @@ class LauncherAliasManifestTest { assertThat(missing).isEmpty() } + + private companion object { + const val LAUNCHER = "android.intent.category.LAUNCHER" + } } diff --git a/app/src/test/java/io/github/aedev/flow/ui/NavigationDestinationsTest.kt b/app/src/test/java/io/github/aedev/flow/ui/NavigationDestinationsTest.kt index 8863b302e4..3d4fb179f1 100644 --- a/app/src/test/java/io/github/aedev/flow/ui/NavigationDestinationsTest.kt +++ b/app/src/test/java/io/github/aedev/flow/ui/NavigationDestinationsTest.kt @@ -40,13 +40,20 @@ class NavigationDestinationsTest { @Test fun routesFromOutsideTheGraphResolveToTheirTab() { - assertEquals(FlowTab.Search, flowTabForRoute("search")) - assertEquals(FlowTab.Music, flowTabForRoute("music")) + assertEquals(FlowTab.Search, flowTabForRoute("search", downloadsTab = false)) + assertEquals(FlowTab.Music, flowTabForRoute("music", downloadsTab = false)) listOf("downloads", "history", "musicRecognize", "musicPlayer/abc", "settings").forEach { route -> - assertNull(flowTabForRoute(route)) + assertNull(flowTabForRoute(route, downloadsTab = false)) } } + @Test + fun theDownloadsPageOpensAsTheTabOnlyWhenTheBarHasIt() { + assertEquals(FlowTab.Downloads, flowTabForRoute("downloads", downloadsTab = true)) + assertNull(flowTabForRoute("downloads", downloadsTab = false)) + assertNull(flowTabForDestination("downloads", shortsSourceArg = null)) + } + @Test fun searchIsTheOnlyTabWithoutTheBar() { FlowTab.entries.forEach { tab -> assertEquals(tab != FlowTab.Search, tab.showsNavigationBar()) } diff --git a/app/src/test/java/io/github/aedev/flow/ui/components/layout/navigation/FlowTabTest.kt b/app/src/test/java/io/github/aedev/flow/ui/components/layout/navigation/FlowTabTest.kt index a589b5b6c7..1271779d7e 100644 --- a/app/src/test/java/io/github/aedev/flow/ui/components/layout/navigation/FlowTabTest.kt +++ b/app/src/test/java/io/github/aedev/flow/ui/components/layout/navigation/FlowTabTest.kt @@ -124,4 +124,13 @@ class FlowTabTest { } } } + + @Test + fun theDownloadsTabIsOffUntilTurnedOn() { + assertFalse(FlowTab.Downloads in visibleFlowTabs(DEFAULT_NAV_TAB_ORDER, NavigationVisibility())) + assertEquals( + FlowTab.Downloads, + visibleFlowTabs(listOf(3, 0), NavigationVisibility(downloads = true)).last(), + ) + } }