Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 13 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,19 @@
android:resource="@xml/widget_downloads_info" />
</receiver>

<!-- Download with Flow in the share menu: a shared link opens the download menu. -->
<activity-alias
android:name=".DownloadShareAlias"
android:exported="true"
android:label="@string/share_download_with_flow"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity-alias>

<!-- App Icon Variants (via activity-alias) -->
<!-- Default: Flow Red -->
<activity-alias
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/assets/changelog/v3.0.1.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
FLOW CHANGE LOG
VERSION: 3.0.1
DATE: 2026-10-19
STATUS: stable
DATE: 2026-10-27
STATUS: pre-release

HIGHLIGHTS
- Android TV is redesigned on Material 3 Expressive, using the same pages as the phone app
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/io/github/aedev/flow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ class MainActivity : ComponentActivity() {
val isDeeplinkShared by links.isShared
val openMusicPlayerRequest by links.musicPlayerRequest
val pendingRoute by links.route
val downloadVideoId by links.downloadVideoId

ProvideChannelGroupLabels {
if (appUiRoot == AppUiRoot.TV) {
Expand All @@ -309,6 +310,8 @@ class MainActivity : ComponentActivity() {
onDeeplinkConsumed = links::consumeVideo,
pendingRoute = pendingRoute,
onPendingRouteConsumed = links::consumeRoute,
downloadVideoId = downloadVideoId,
onDownloadConsumed = links::consumeDownload,
onStartDestinationKnown = { splashController.contentReady = true },
)
}
Expand Down Expand Up @@ -401,6 +404,12 @@ class MainActivity : ComponentActivity() {
}

val linkText = linkTextOf(intent)
if (linkText != null && intent.component?.className?.endsWith(DOWNLOAD_SHARE_ALIAS) == true) {
if (!isRestoringState && !links.openDownload(linkText)) {
Toast.makeText(this, R.string.share_download_not_a_video, Toast.LENGTH_LONG).show()
}
return
}
if (linkText != null) {
val understood = links.openLink(linkText, isShared = intent.action == Intent.ACTION_SEND, isRestoring = isRestoringState)
if (!understood && !isRestoringState) Toast.makeText(this, R.string.link_not_supported, Toast.LENGTH_LONG).show()
Expand Down Expand Up @@ -729,5 +738,8 @@ class MainActivity : ComponentActivity() {

companion object {
const val EXTRA_BENCHMARK_BYPASS_ONBOARDING = "io.github.aedev.flow.extra.BENCHMARK_BYPASS_ONBOARDING"

/** The share-menu entry Download with Flow: an activity-alias of this activity in the manifest. */
private const val DOWNLOAD_SHARE_ALIAS = ".DownloadShareAlias"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const val MAX_CONCURRENT_DOWNLOADS = 5
/** On until turned off, matching NewPipe's default and the Background button (#1298). */
const val DEFAULT_BACKGROUND_PLAY_ENABLED = true

val DEFAULT_NAV_TAB_ORDER = listOf(0, 1, 2, 3, 4, 5, 6)
val DEFAULT_NAV_TAB_ORDER = listOf(0, 1, 2, 3, 4, 5, 6, 7)

private const val MAX_UNPLAYABLE_VIDEO_IDS = 1_000

Expand Down Expand Up @@ -133,6 +133,8 @@ class PlayerPreferences(
// Download dialog style + remembered last-used download options (compact dialog)
val DOWNLOAD_DIALOG_STYLE = stringPreferencesKey("download_dialog_style")
val DOWNLOAD_ASK_EVERY_TIME = booleanPreferencesKey("download_ask_every_time")
val DOWNLOADS_STORAGE_CARD = booleanPreferencesKey("downloads_storage_card")
val DOWNLOADS_CONFIRM_DELETE = booleanPreferencesKey("downloads_confirm_delete")
val LAST_DOWNLOAD_TYPE = stringPreferencesKey("last_download_type")
val LAST_DOWNLOAD_HEIGHT = intPreferencesKey("last_download_height")
val LAST_DOWNLOAD_CODEC = stringPreferencesKey("last_download_codec")
Expand Down Expand Up @@ -200,6 +202,7 @@ class PlayerPreferences(
val MUSIC_NAVIGATION_ENABLED = booleanPreferencesKey("music_navigation_enabled")
val SEARCH_NAV_TAB_ENABLED = booleanPreferencesKey("search_nav_tab_enabled")
val CATEGORIES_NAV_TAB_ENABLED = booleanPreferencesKey("categories_nav_tab_enabled")
val DOWNLOADS_NAV_TAB_ENABLED = booleanPreferencesKey("downloads_nav_tab_enabled")
val PREFERRED_LYRICS_PROVIDER = stringPreferencesKey("preferred_lyrics_provider")
val LYRICS_PROVIDER_ORDER = stringPreferencesKey("lyrics_provider_order")
val LYRICS_TEXT_ALIGN = stringPreferencesKey("lyrics_text_align")
Expand Down Expand Up @@ -1246,6 +1249,16 @@ class PlayerPreferences(
}
}

val downloadsNavigationEnabled: Flow<Boolean> =
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<Boolean> =
context.playerPreferencesDataStore.data
Expand Down Expand Up @@ -3214,6 +3227,28 @@ class PlayerPreferences(
}
}

/** The space used and free, shown at the top of the Downloads page. */
val downloadsStorageCard: Flow<Boolean> =
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<Boolean> =
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<AutoDownloadMode> =
context.playerPreferencesDataStore.data
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
15 changes: 9 additions & 6 deletions app/src/main/java/io/github/aedev/flow/ui/AppHooks.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,14 +35,15 @@ fun HandlePendingRoute(
pendingRoute: String?,
navController: NavController,
startRoute: String,
downloadsTab: suspend () -> Boolean,
onConsumed: () -> Unit,
onBeforeNavigate: () -> Unit = {},
) {
LaunchedEffect(pendingRoute) {
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)
Expand Down Expand Up @@ -87,10 +90,11 @@ private const val OFFLINE_NOTICE_DELAY_MS = 3_000L
@Composable
fun OfflineMonitor(
context: Context,
navController: NavController,
snackbarHostState: SnackbarHostState,
currentRoute: State<String>,
onOpenDownloads: () -> Unit,
) {
val openDownloads by rememberUpdatedState(onOpenDownloads)
val connectivity = remember(context) { NetworkConnectivityObserver(context) }
val isConnected by remember(connectivity) { connectivity.observeConnectivity() }
.collectAsStateWithLifecycle(initialValue = true)
Expand All @@ -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" ||
Expand All @@ -116,9 +121,7 @@ fun OfflineMonitor(
duration = SnackbarDuration.Short,
)
if (result == SnackbarResult.ActionPerformed) {
navController.navigate("downloads") {
launchSingleTop = true
}
openDownloads()
}
}
}
57 changes: 57 additions & 0 deletions app/src/main/java/io/github/aedev/flow/ui/DownloadsRoutes.kt
Original file line number Diff line number Diff line change
@@ -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<String>,
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 }
}
},
)
}
}
}
15 changes: 14 additions & 1 deletion app/src/main/java/io/github/aedev/flow/ui/FlowApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,6 +84,8 @@ fun FlowApp(
onDeeplinkConsumed: () -> Unit = {},
pendingRoute: String? = null,
onPendingRouteConsumed: () -> Unit = {},
downloadVideoId: String? = null,
onDownloadConsumed: () -> Unit = {},
onStartDestinationKnown: () -> Unit = {},
) {
val context = LocalContext.current
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -491,6 +502,7 @@ fun FlowApp(
LocalMusicMenus provides musicMenus,
LocalEqualizerState provides equalizerViewModel.state,
LocalFlowBottomInsets provides bottomInsets,
LocalFlowSnackbarHostState provides snackbarHostState,
LocalStatusBarHero provides statusBarHero,
) {
NavHost(
Expand All @@ -516,6 +528,7 @@ fun FlowApp(
disableShortsPlayer = disableShortsPlayer,
defaultStartRoute = defaultStartRoute,
onMusicStarted = onMusicStarted,
openDownloads = openDownloads,
)
}
}
Expand Down
Loading
Loading