From 1dbadf4771126519ba6a06527d9290ec21776779 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:49:53 +0200 Subject: [PATCH 1/9] fix(music): headphones resume music only after music played, never after Close or a video --- .../flow/data/local/PlayerPreferences.kt | 12 +++++++ .../aedev/flow/player/PlaybackShutdown.kt | 2 ++ .../aedev/flow/service/HeadphoneResume.kt | 33 +++++++++++++++++++ .../aedev/flow/service/Media3MusicService.kt | 18 +++++++--- .../aedev/flow/service/VideoPlayerService.kt | 13 ++++++++ 5 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/io/github/aedev/flow/service/HeadphoneResume.kt diff --git a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt index 5cef3659f9..d8e9c36598 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt @@ -77,6 +77,7 @@ class PlayerPreferences( val QUEUE_AUTOPLAY_ENABLED = booleanPreferencesKey("queue_autoplay_enabled") val START_VIDEOS_PAUSED = booleanPreferencesKey("start_videos_paused") val OPEN_VIDEOS_FULLSCREEN = booleanPreferencesKey("open_videos_fullscreen") + val RESUME_FROM_HEADPHONES = booleanPreferencesKey("resume_from_headphones") val MUSIC_ENDLESS_RADIO_ENABLED = booleanPreferencesKey("music_endless_radio_enabled") val AUTOPLAY_COUNTDOWN_SECONDS = intPreferencesKey("autoplay_countdown_seconds") val SHOW_CONTROLS_WHILE_LOADING = booleanPreferencesKey("show_controls_while_loading") @@ -1626,6 +1627,17 @@ class PlayerPreferences( } } + /** A Play button on headphones may start the last songs after Flow was closed. */ + val resumeFromHeadphones: Flow = + context.playerPreferencesDataStore.data + .map { preferences -> preferences[Keys.RESUME_FROM_HEADPHONES] ?: true } + + suspend fun setResumeFromHeadphones(enabled: Boolean) { + context.playerPreferencesDataStore.edit { preferences -> + preferences[Keys.RESUME_FROM_HEADPHONES] = enabled + } + } + // Music endless radio — its own switch, independent of the VIDEO autoplay toggles. val musicEndlessRadioEnabled: Flow = context.playerPreferencesDataStore.data diff --git a/app/src/main/java/io/github/aedev/flow/player/PlaybackShutdown.kt b/app/src/main/java/io/github/aedev/flow/player/PlaybackShutdown.kt index b527d2de4e..11aa24c7ab 100644 --- a/app/src/main/java/io/github/aedev/flow/player/PlaybackShutdown.kt +++ b/app/src/main/java/io/github/aedev/flow/player/PlaybackShutdown.kt @@ -2,6 +2,7 @@ package io.github.aedev.flow.player import android.app.ActivityManager import android.content.Context +import io.github.aedev.flow.service.HeadphoneResume /** * The sleep timer's Close app. It ends both players the way closing them yourself does, so their @@ -11,6 +12,7 @@ import android.content.Context */ internal object PlaybackShutdown { fun closeApp(context: Context) { + HeadphoneResume.set(context, allowed = false) EnhancedPlayerManager.getInstance().stopBackgroundService() GlobalPlayerState.release() EnhancedMusicPlayerManager.clearCurrentTrack() diff --git a/app/src/main/java/io/github/aedev/flow/service/HeadphoneResume.kt b/app/src/main/java/io/github/aedev/flow/service/HeadphoneResume.kt new file mode 100644 index 0000000000..b44240f996 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/service/HeadphoneResume.kt @@ -0,0 +1,33 @@ +package io.github.aedev.flow.service + +import android.content.Context +import io.github.aedev.flow.utils.PerformanceDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.io.File + +/** + * Whether a Play button on headphones may start the saved music queue after Flow was closed (#1450). + * It is off after Close, after a video plays, and while the setting is off; music playing with the + * setting on turns it back on. Media3 asks in `onGetSession`, on the main thread of a process the + * button just started and before a DataStore read could finish, so the answer is a marker file. + */ +internal object HeadphoneResume { + private const val MARKER = "headphone_resume_off" + + // One writer at a time, so an allow and a block issued in order land in that order. + private val scope = CoroutineScope(SupervisorJob() + PerformanceDispatcher.diskIO.limitedParallelism(1)) + + fun isAllowed(context: Context): Boolean = !marker(context).exists() + + fun set( + context: Context, + allowed: Boolean, + ) { + val file = marker(context.applicationContext) + scope.launch { if (allowed) file.delete() else file.createNewFile() } + } + + private fun marker(context: Context) = File(context.noBackupFilesDir, MARKER) +} diff --git a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt index 14cbb49aa5..00f33a3eb0 100644 --- a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt +++ b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt @@ -648,6 +648,9 @@ class Media3MusicService : MediaLibraryService() { updateLocks(isPlaybackActive()) widgetPublisher.publish(player) if (isPlaying) { + lifecycleScope.launch { + HeadphoneResume.set(this@Media3MusicService, prefs.resumeFromHeadphones.first()) + } val playingId = player.currentMediaItem?.mediaId if (learnMediaId == null) learnMediaId = playingId?.let(videoSwitch::listenId) if (learnTrack?.videoId != learnMediaId) learnTrack = listenTrack(playingId) @@ -1262,6 +1265,7 @@ class Media3MusicService : MediaLibraryService() { } private fun stopPlaybackAndService() { + HeadphoneResume.set(this, allowed = false) retryJobCancel() waitingForNetwork = false if (::player.isInitialized) { @@ -1341,7 +1345,7 @@ class Media3MusicService : MediaLibraryService() { mediaLibrarySession.takeUnless { controllerInfo.declinesMediaButtonStart( hasMediaItem = ::player.isInitialized && player.currentMediaItem != null, - canResume = queuePersistence.hasSavedQueueFile(), + canResume = queuePersistence.hasSavedQueueFile() && HeadphoneResume.isAllowed(this), ) } @@ -1359,12 +1363,18 @@ class Media3MusicService : MediaLibraryService() { */ @OptIn(UnstableApi::class) override fun onTaskRemoved(rootIntent: Intent?) { - if (::player.isInitialized && player.keepsPlayingOnTaskRemoved(isPlaybackOngoing())) { - return - } + val keepsPlaying = ::player.isInitialized && player.keepsPlayingOnTaskRemoved(isPlaybackOngoing()) + PlayerDiagnostics.logInfo(TAG, "onTaskRemoved keepsPlaying=$keepsPlaying ${taskRemovalSnapshot()}") + if (keepsPlaying) return pauseAllPlayersAndStopSelf() } + private fun taskRemovalSnapshot(): String { + if (!::player.isInitialized) return "player=none" + return "state=${player.playbackState} pwr=${player.playWhenReady} items=${player.mediaItemCount} " + + "ongoing=${runCatching { isPlaybackOngoing() }.getOrDefault(false)}" + } + override fun onDestroy() { MusicServiceState.isRunning = false // Flush the in-flight listen session before the player goes away. diff --git a/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt b/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt index cfed920378..364de957f4 100644 --- a/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt +++ b/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt @@ -22,6 +22,9 @@ import io.github.aedev.flow.utils.FlowCrashHandler import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @UnstableApi @@ -106,6 +109,16 @@ class VideoPlayerService : MediaSessionService() { updateLocks(isPlaybackActiveForLocks()) } } + lifecycleScope.launch { + // A headphone Play after a video must not start the saved music queue (#1450). + EnhancedPlayerManager + .getInstance() + .playerState + .map { it.isPlaying } + .distinctUntilChanged() + .filter { it } + .collect { HeadphoneResume.set(this@VideoPlayerService, allowed = false) } + } } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { From 5305c196f28667e8d3eb94eabb5910d22c22fb55 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:49:53 +0200 Subject: [PATCH 2/9] feat(settings): Resume from headphones switch and a background activity row --- .../ui/screens/settings/index/PlaybackIndex.kt | 9 +++++++++ .../settings/playback/PlaybackSections.kt | 17 +++++++++++++++++ .../settings/playback/PlaybackSettingsScreen.kt | 13 +++++++++++++ .../playback/PlaybackSettingsViewModel.kt | 10 ++++++++++ app/src/main/res/values/strings.xml | 4 ++++ 5 files changed, 53 insertions(+) diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlaybackIndex.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlaybackIndex.kt index f34d398363..f6617b34e1 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlaybackIndex.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlaybackIndex.kt @@ -493,6 +493,14 @@ internal object PlaybackIndex { section = R.string.settings_section_music, destination = page, ) + val resumeFromHeadphones = + SettingEntry( + key = "playback.resume_from_headphones", + title = R.string.settings_resume_from_headphones_title, + summary = R.string.settings_resume_from_headphones_summary, + section = R.string.settings_section_music, + destination = page, + ) val musicSideSwipes = SettingEntry( key = "playback.music_side_swipes", @@ -580,6 +588,7 @@ internal object PlaybackIndex { endlessRadio, pauseMusicWhenMuted, musicResume, + resumeFromHeadphones, musicSideSwipes, musicVideoSwitch, lyricsProviders, diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSections.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSections.kt index 9154d830af..8deba60771 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSections.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSections.kt @@ -1,5 +1,7 @@ package io.github.aedev.flow.ui.screens.settings.playback +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.BatteryAlert import androidx.compose.runtime.getValue import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -16,6 +18,7 @@ import io.github.aedev.flow.ui.components.settings.choice import io.github.aedev.flow.ui.components.settings.nav import io.github.aedev.flow.ui.components.settings.switch import io.github.aedev.flow.ui.components.settings.toggleGroup +import io.github.aedev.flow.ui.components.shared.FlowNavRow import io.github.aedev.flow.ui.components.shared.FlowSwitchRow import io.github.aedev.flow.ui.components.shared.FlowToggleOption import io.github.aedev.flow.ui.screens.settings.index.DestinationIndex @@ -28,9 +31,22 @@ internal fun SettingsListScope.playbackSections( state: PlaybackStructure, openDialog: (PlaybackDialog) -> Unit, onNavigate: (SettingsTarget) -> Unit, + onAllowBackground: () -> Unit, ) { group(key = "playback.general", header = R.string.playback_header) { switch(PlaybackIndex.backgroundPlay, viewModel.backgroundPlay, viewModel::setBackgroundPlay) + if (state.backgroundRestricted) { + row("playback.background_restricted") { shape -> + FlowNavRow( + title = stringResource(R.string.playback_background_restricted_title), + supportingText = stringResource(R.string.playback_background_restricted_subtitle), + leadingIcon = Icons.Outlined.BatteryAlert, + showChevron = false, + shape = shape, + onClick = onAllowBackground, + ) + } + } switch(PlaybackIndex.startPaused, viewModel.startPaused, viewModel::setStartPaused) switch(PlaybackIndex.openFullscreen, viewModel.openFullscreen, viewModel::setOpenFullscreen) switch(PlaybackIndex.autoplay, viewModel.autoplay, viewModel::setAutoplay, enabled = !state.loopAll) @@ -142,6 +158,7 @@ internal fun SettingsListScope.playbackSections( switch(PlaybackIndex.endlessRadio, viewModel.endlessRadio, viewModel::setEndlessRadio) switch(PlaybackIndex.pauseMusicWhenMuted, viewModel.pauseMusicWhenMuted, viewModel::setPauseMusicWhenMuted) switch(PlaybackIndex.musicResume, viewModel.musicResume, viewModel::setMusicResume) + switch(PlaybackIndex.resumeFromHeadphones, viewModel.resumeFromHeadphones, viewModel::setResumeFromHeadphones) switch(PlaybackIndex.musicSideSwipes, viewModel.musicSideSwipes, viewModel::setMusicSideSwipes) switch(PlaybackIndex.musicVideoSwitch, viewModel.musicVideoSwitch, viewModel::setMusicVideoSwitch) choice(PlaybackIndex.lyricsProviders, onClick = { openDialog(PlaybackDialog.LYRICS) }) { diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsScreen.kt index ca0b8c5bb9..2b5fbc678f 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsScreen.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsScreen.kt @@ -3,13 +3,17 @@ package io.github.aedev.flow.ui.screens.settings.playback import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.aedev.flow.R import io.github.aedev.flow.data.local.SpeedSwipeSide +import io.github.aedev.flow.notification.BackgroundWorkPolicy import io.github.aedev.flow.ui.components.settings.SettingsPage import io.github.aedev.flow.ui.components.settings.SettingsTarget @@ -47,6 +51,12 @@ internal fun PlaybackSettingsScreen( val shortsContent by viewModel.shortsContent.collectAsStateWithLifecycle() val disableShortsPlayer by viewModel.disableShortsPlayer.collectAsStateWithLifecycle() var dialog by rememberSaveable { mutableStateOf(null) } + val context = LocalContext.current + var backgroundAllowed by remember { mutableStateOf(BackgroundWorkPolicy.isBackgroundWorkUnrestricted(context)) } + LifecycleResumeEffect(Unit) { + backgroundAllowed = BackgroundWorkPolicy.isBackgroundWorkUnrestricted(context) + onPauseOrDispose { } + } SettingsPage( title = stringResource(R.string.settings_playback_title), @@ -65,9 +75,11 @@ internal fun PlaybackSettingsScreen( comments, shortsContent, disableShortsPlayer, + backgroundRestricted = !backgroundAllowed, ), openDialog = { dialog = it }, onNavigate = onNavigate, + onAllowBackground = { BackgroundWorkPolicy.requestUnrestrictedBackgroundWork(context) }, ) } @@ -86,4 +98,5 @@ internal data class PlaybackStructure( val comments: Boolean, val shortsContent: Boolean, val disableShortsPlayer: Boolean, + val backgroundRestricted: Boolean, ) diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsViewModel.kt index 649950052b..e9d01d8266 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsViewModel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsViewModel.kt @@ -1,6 +1,8 @@ package io.github.aedev.flow.ui.screens.settings.playback +import android.content.Context import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import io.github.aedev.flow.data.local.DEFAULT_BACKGROUND_PLAY_ENABLED import io.github.aedev.flow.data.local.DEFAULT_SPEED_SWIPE_STEP import io.github.aedev.flow.data.local.DoubleTapSeekZone @@ -10,6 +12,7 @@ import io.github.aedev.flow.data.local.PlayerRelatedCardStyle import io.github.aedev.flow.data.local.SpeedSwipeSide import io.github.aedev.flow.data.lyrics.LyricsProviderOrder import io.github.aedev.flow.player.stream.CaptionTrackResolver +import io.github.aedev.flow.service.HeadphoneResume import io.github.aedev.flow.ui.screens.settings.SettingsViewModel import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map @@ -26,6 +29,7 @@ data class LyricsProviderState( class PlaybackSettingsViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val preferences: PlayerPreferences, ) : SettingsViewModel() { private val overlay = PlayerOverlayPreferences() @@ -103,6 +107,7 @@ class PlaybackSettingsViewModel val musicVideoSwitch = preferences.musicVideoSwitch.asState(false) val pauseMusicWhenMuted = preferences.pauseMusicWhenMuted.asState(false) val musicResume = preferences.musicResumeLongTracks.asState(false) + val resumeFromHeadphones = preferences.resumeFromHeadphones.asState(true) val musicSideSwipes = preferences.musicSideSwipes.asState(false) val normalizeVolume = preferences.videoVolumeNormalizationEnabled.asState(true) val lyricsProviders = @@ -112,6 +117,11 @@ class PlaybackSettingsViewModel fun setBackgroundPlay(value: Boolean) = write { preferences.setBackgroundPlayEnabled(value) } + fun setResumeFromHeadphones(value: Boolean) { + if (!value) HeadphoneResume.set(context, allowed = false) + write { preferences.setResumeFromHeadphones(value) } + } + fun setStartPaused(value: Boolean) = write { preferences.setStartVideosPaused(value) } fun setOpenFullscreen(value: Boolean) = write { preferences.setOpenVideosFullscreen(value) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8118ec969..d47d51fcb2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2156,6 +2156,10 @@ Resume when the volume comes back Resume podcasts and long tracks Start tracks over 10 minutes where you left off + Resume from headphones + A Play button on headphones restarts your last songs + Background activity is restricted + Your phone may stop Flow when you swipe it away. Tap to allow it. Speed and volume swipes Swipe the left side for speed, the right for volume Open the music video in the video player From d328038817bad68149a4e9fda9146824fdf14420 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:59:38 +0200 Subject: [PATCH 3/9] feat(menu): Download joins Save and Watch later, Share moves to Options --- .../components/shared/MediaDownloadDialog.kt | 308 ------------------ .../quickactions/VideoQuickActionsSheet.kt | 37 +-- .../components/shared/DownloadDialogsTest.kt | 148 --------- 3 files changed, 15 insertions(+), 478 deletions(-) delete mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadDialog.kt delete mode 100644 app/src/test/java/io/github/aedev/flow/ui/components/shared/DownloadDialogsTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadDialog.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadDialog.kt deleted file mode 100644 index a70a3c15bc..0000000000 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadDialog.kt +++ /dev/null @@ -1,308 +0,0 @@ -package io.github.aedev.flow.ui.components.shared - -import android.widget.Toast -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.GraphicEq -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import io.github.aedev.flow.R -import io.github.aedev.flow.data.model.Video -import io.github.aedev.flow.data.video.DownloadStreamPolicy -import io.github.aedev.flow.data.video.downloader.request.DownloadSubtitle -import io.github.aedev.flow.player.state.SubtitleOption -import io.github.aedev.flow.player.stream.VideoCodecUtils - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MediaDownloadDialog( - streamSizes: Map, - innerTubeVideoFormats: List = emptyList(), - innerTubeAudioFormats: List = emptyList(), - video: Video, - subtitles: List = emptyList(), - onDismiss: () -> Unit, -) { - val context = LocalContext.current - val audioLangPref = - remember(context) { - io.github.aedev.flow.data.local - .PlayerPreferences(context) - } - val preferredLang by audioLangPref.preferredAudioLanguage.collectAsState(initial = "") - val lastSubtitleLanguage by audioLangPref.lastDownloadSubtitleLanguage.collectAsState(initial = null) - val subtitleOptions = remember(subtitles) { downloadableSubtitles(subtitles) } - var subtitleChoice by remember(subtitleOptions, lastSubtitleLanguage) { - mutableStateOf(defaultDownloadSubtitle(subtitleOptions, lastSubtitleLanguage)) - } - - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - shape = AlertDialogDefaults.shape, - color = AlertDialogDefaults.containerColor, - tonalElevation = AlertDialogDefaults.TonalElevation, - ) { - Column( - modifier = - Modifier.padding( - start = FlowDialogDefaults.ContentPadding, - top = FlowDialogDefaults.ContentPadding, - end = FlowDialogDefaults.ContentPadding, - bottom = FlowDialogDefaults.BottomPadding, - ), - ) { - Text( - text = stringResource(R.string.download_video), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(R.string.select_quality), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - if (subtitleOptions.isNotEmpty()) { - Spacer(modifier = Modifier.height(12.dp)) - DownloadSubtitleRow( - options = subtitleOptions, - selected = subtitleChoice, - onSelect = { subtitleChoice = it }, - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - val effectiveAudioForDownload = - remember(innerTubeAudioFormats) { DownloadStreamPolicy.buildDownloadAudioFormats(innerTubeAudioFormats) } - val audioFormats = - remember(effectiveAudioForDownload) { - effectiveAudioForDownload.sortedByDescending { it.averageBitrate ?: it.bitrate } - } - val distinctFormats = - remember(innerTubeVideoFormats) { DownloadStreamPolicy.buildDownloadVideoFormats(innerTubeVideoFormats) } - val hdrLabel = stringResource(R.string.download_quality_hdr) - - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.heightIn(max = 400.dp), - ) { - if (distinctFormats.isEmpty()) { - item { - Text(stringResource(R.string.no_download_streams), modifier = Modifier.padding(16.dp)) - } - item { - Button( - onClick = { - onDismiss() - DownloadLauncher.startDefaultDownload(context, video) - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.ui_try_sabr_download)) - } - } - } - - itemsIndexed(distinctFormats) { streamIndex, format -> - val codecKey = DownloadStreamPolicy.videoCodecKey(format) - val qualityHeight = DownloadStreamPolicy.videoHeight(format) - val qualityLabel = DownloadStreamPolicy.videoQualityLabel(format, hdrLabel) - - val sizeText = - approxDownloadSizeLabel(streamSizes[VideoCodecUtils.streamSizeKey(qualityHeight, codecKey)]) - - val resBadge = - when { - qualityHeight >= 2160 -> R.string.filter_4k - qualityHeight >= 1440 -> R.string.quality_badge_2k - qualityHeight >= 1080 -> R.string.filter_hd - else -> null - } - - Surface( - onClick = downloadVideo@{ - onDismiss() - val compatibleAudio = - DownloadStreamPolicy.pickAacAudio( - allAudio = effectiveAudioForDownload, - preferredLang = preferredLang, - ) - if (compatibleAudio == null) { - Toast - .makeText( - context, - context.getString(R.string.download_no_compatible_audio), - Toast.LENGTH_LONG, - ).show() - return@downloadVideo - } - DownloadLauncher.startVideoDownload( - context, - video, - format, - compatibleAudio, - subtitle = - subtitleChoice?.toDownloadSubtitle() ?: DownloadSubtitle.OFF.takeIf { - subtitleOptions.isNotEmpty() - }, - ) - if (subtitleOptions.isNotEmpty()) rememberDownloadSubtitleChoice(audioLangPref, subtitleChoice) - }, - shape = flowRowGroupShape(streamIndex, distinctFormats.size), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = qualityLabel, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - if (sizeText != null) { - Text( - text = sizeText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - if (resBadge != null) { - Spacer(modifier = Modifier.width(8.dp)) - MediaTextBadge( - text = stringResource(resBadge), - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - - // ===== Audio-Only Section ===== - if (audioFormats.isNotEmpty()) { - item { - Spacer(modifier = Modifier.height(8.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.ui_audio_only), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(vertical = 4.dp), - ) - } - - itemsIndexed(audioFormats) { audioIndex, audioStream -> - val bitrate = DownloadStreamPolicy.audioBitrateKbps(audioStream) - val bitrateLabel = "$bitrate${stringResource(R.string.kbps)}" - val audioFormat = - DownloadStreamPolicy.audioFormatLabel(audioStream, stringResource(R.string.audio_format_unknown)) - val languageLabel = DownloadStreamPolicy.audioLanguageLabel(audioStream) - val trackTypeLabel = - DownloadStreamPolicy.audioTrackTypeLabel( - format = audioStream, - originalLabel = stringResource(R.string.audio_track_original), - dubbedLabel = stringResource(R.string.audio_track_dubbed), - ) - - Surface( - onClick = { - onDismiss() - if (DownloadLauncher.startAudioOnlyDownload(context, video, audioStream)) { - Toast - .makeText( - context, - context.getString(R.string.ui_download_audio, bitrate, audioFormat), - Toast.LENGTH_SHORT, - ).show() - } - }, - shape = flowRowGroupShape(audioIndex, audioFormats.size), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier - .size(40.dp) - .background( - MaterialTheme.colorScheme.secondaryContainer, - CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.GraphicEq, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.size(20.dp), - ) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = "$audioFormat $bitrateLabel", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = - listOfNotNull(languageLabel, trackTypeLabel, stringResource(R.string.ui_audio_only)) - .joinToString(stringResource(R.string.list_separator_dot)), - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - } - } - } - - Spacer(modifier = Modifier.height(FlowDialogDefaults.ActionsSpacing)) - - TextButton( - onClick = onDismiss, - modifier = Modifier.align(Alignment.End), - ) { - Text(stringResource(R.string.cancel)) - } - } - } - } -} diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/VideoQuickActionsSheet.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/VideoQuickActionsSheet.kt index f5718bbaa4..57dae6f595 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/VideoQuickActionsSheet.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/VideoQuickActionsSheet.kt @@ -4,7 +4,7 @@ import android.content.ClipData import android.os.Build import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.PlaylistAdd -import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.DownloadDone import androidx.compose.material.icons.filled.WatchLater import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.ChevronRight @@ -168,7 +168,8 @@ private fun VideoPrimaryActions( onDismiss: () -> Unit, ) { val watchLaterIds by viewModel.watchLaterIds.collectAsStateWithLifecycle() - val share = rememberVideoShareAction() + val downloadedIds by viewModel.downloadedVideoIds.collectAsStateWithLifecycle() + val isDownloaded = video.id in downloadedIds QuickActionsPrimaryGroup( listOf( QuickPrimaryAction(icon = Icons.AutoMirrored.Outlined.PlaylistAdd, label = stringResource(R.string.save), onClick = onSave), @@ -180,11 +181,15 @@ private fun VideoPrimaryActions( onClick = { viewModel.toggleWatchLater(video) }, ), QuickPrimaryAction( - icon = Icons.Outlined.Share, - label = stringResource(R.string.share), + icon = Icons.Outlined.Download, + checkedIcon = Icons.Filled.DownloadDone, + label = stringResource(if (isDownloaded) R.string.downloaded else R.string.download), + checked = isDownloaded, onClick = { - share(video.id, video.title, video.isShort) - onDismiss() + if (!isDownloaded) { + viewModel.requestDownload(video) + onDismiss() + } }, ), ), @@ -346,25 +351,13 @@ private fun moreRows( onDismiss: () -> Unit, onOpenDetails: () -> Unit, ): List { - val downloadedIds by viewModel.downloadedVideoIds.collectAsStateWithLifecycle() - val isDownloaded = video.id in downloadedIds val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() - val downloadedLabel = stringResource(R.string.downloaded) + val share = rememberVideoShareAction() return listOf( - QuickActionRow("download") { shape -> - FlowNavRow( - title = if (isDownloaded) downloadedLabel else stringResource(R.string.download), - leadingIcon = if (isDownloaded) Icons.Filled.CheckCircle else Icons.Outlined.Download, - onClick = { - viewModel.requestDownload(video) - onDismiss() - }, - enabled = !isDownloaded, - showChevron = false, - stateDescription = if (isDownloaded) downloadedLabel else null, - shape = shape, - ) + actionRow("share", Icons.Outlined.Share, stringResource(R.string.share)) { + share(video.id, video.title, video.isShort) + onDismiss() }, actionRow("copy_link", Icons.Outlined.ContentCopy, stringResource(R.string.copy_video_link)) { scope.launch { diff --git a/app/src/test/java/io/github/aedev/flow/ui/components/shared/DownloadDialogsTest.kt b/app/src/test/java/io/github/aedev/flow/ui/components/shared/DownloadDialogsTest.kt deleted file mode 100644 index d4155896d6..0000000000 --- a/app/src/test/java/io/github/aedev/flow/ui/components/shared/DownloadDialogsTest.kt +++ /dev/null @@ -1,148 +0,0 @@ -package io.github.aedev.flow.ui.components.shared - -import android.app.Application -import android.content.Context -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.semantics.getOrNull -import androidx.compose.ui.test.hasClickAction -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.isRoot -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import io.github.aedev.flow.R -import io.github.aedev.flow.data.video.DownloadStreamPolicy -import io.github.aedev.flow.ui.screens.player.fakeAudioFormats -import io.github.aedev.flow.ui.screens.player.fakeVideo -import io.github.aedev.flow.ui.screens.player.fakeVideoFormats -import io.mockk.every -import io.mockk.mockkObject -import io.mockk.unmockkAll -import org.junit.After -import org.junit.Ignore -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.annotation.Config - -/** - * Pins that the full and compact download dialogs derive the same resolution ladder from the - * same InnerTube formats, so one can replace the other without losing an option. - */ -@RunWith(AndroidJUnit4::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w411dp-h891dp") -class DownloadDialogsTest { - @get:Rule - val rule = createComposeRule() - - private val context: Context - get() = ApplicationProvider.getApplicationContext() - - private var showCompact by mutableStateOf(false) - - @After - fun tearDown() { - unmockkAll() - } - - private fun setDialogs() { - val video = fakeVideo() - rule.setContent { - MaterialTheme { - if (showCompact) { - MediaDownloadDialogCompact( - streamSizes = emptyMap(), - innerTubeVideoFormats = fakeVideoFormats(), - innerTubeAudioFormats = fakeAudioFormats(), - video = video, - onDismiss = {}, - ) - } else { - MediaDownloadDialog( - streamSizes = emptyMap(), - innerTubeVideoFormats = fakeVideoFormats(), - innerTubeAudioFormats = fakeAudioFormats(), - video = video, - onDismiss = {}, - ) - } - } - } - rule.waitForIdle() - } - - /** Every height that appears in a rendered text node shaped like `... 1080p` or `1080p`. */ - private fun renderedHeights(): Set = - rule - .onAllNodes(!isRoot(), useUnmergedTree = true) - .fetchSemanticsNodes() - .flatMap { node -> node.config.getOrNull(SemanticsProperties.Text).orEmpty() } - .mapNotNull { text -> - HEIGHT_LABEL - .matchEntire(text.text) - ?.groupValues - ?.get(1) - ?.toInt() - }.toSet() - - @Test - fun fullDialogListsEveryResolutionAndAnAudioOnlySection() { - setDialogs() - - rule.onNodeWithText(context.getString(R.string.download_video)).assertExists() - assertThat(renderedHeights()).containsExactly(1080, 720) - rule.onNodeWithText(context.getString(R.string.ui_audio_only)).assertExists() - } - - @Test - fun fullDialogPairsEveryCodecWithTheAacTheSharedHelperPicks() { - mockkObject(DownloadLauncher) - val audioItags = mutableListOf() - every { - DownloadLauncher.startVideoDownload(any(), any(), any(), capture(audioFormats), any()) - } answers { audioItags += audioFormats.last().itag } - - setDialogs() - rule.onNode(hasText("H264 1080p") and hasClickAction()).performClick() - rule.waitForIdle() - rule.onNode(hasText("VP9 1080p") and hasClickAction()).performClick() - rule.waitForIdle() - - val aac = DownloadStreamPolicy.pickAacAudio(DownloadStreamPolicy.buildDownloadAudioFormats(fakeAudioFormats()), "") - assertThat(aac?.mimeType).startsWith("audio/mp4") - assertThat(audioItags).containsExactly(aac?.itag, aac?.itag) - } - - private val audioFormats = mutableListOf() - - @Test - @Ignore( - "Any Compose text field inside a Dialog window never reaches idle under Robolectric with " + - "Compose 1.13.0-alpha01 (verified with filled/outlined, label/no label, NATIVE and LEGACY " + - "graphics); the compact dialog's title field trips it. Device twin: DownloadDialogsInstrumentedTest.", - ) - fun compactDialogOffersTheSameResolutionsAsTheFullDialog() { - setDialogs() - val fullHeights = renderedHeights() - - rule.runOnUiThread { showCompact = true } - rule.waitForIdle() - rule.onNodeWithText(context.getString(R.string.download_title_label)).assertExists() - rule.onNode(hasText("1080p") and hasClickAction()).performClick() - rule.waitForIdle() - - assertThat(renderedHeights()).isEqualTo(fullHeights) - assertThat(fullHeights).containsExactly(1080, 720) - } - - private companion object { - val HEIGHT_LABEL = Regex("""^(?:[A-Z0-9]+ )?(\d+)p$""") - } -} From e1147e7e7273b8e86bc0f6696215cb819f8ce5e5 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:59:38 +0200 Subject: [PATCH 4/9] feat(downloads): new Classic download sheet, Ask every time, and Download quality selected first --- .../shared/DownloadDialogsInstrumentedTest.kt | 12 +- .../flow/data/local/PlayerPreferences.kt | 12 + .../github/aedev/flow/ui/MediaNavigation.kt | 6 + .../layout/navigation/MediaNavigator.kt | 7 +- .../components/shared/DownloadChoiceTile.kt | 106 +++++ .../ui/components/shared/DownloadLauncher.kt | 34 ++ .../components/shared/DownloadSheetChoices.kt | 73 ++++ .../components/shared/MediaDownloadPrompt.kt | 109 +++++ .../components/shared/MediaDownloadSheet.kt | 384 ++++++++++++++++++ .../shared/quickactions/QuickActionsHost.kt | 41 +- .../shared/quickactions/QuickActionsSheet.kt | 6 +- .../components/shorts/ShortsReelPageState.kt | 4 - .../shorts/ShortsSettingsSheetState.kt | 34 +- .../player/dialogs/PlayerDialogsContainer.kt | 39 +- .../state/VideoPlayerPreferencesState.kt | 4 - .../downloads/DownloadSettingsScreen.kt | 1 + .../downloads/DownloadSettingsViewModel.kt | 3 + .../screens/settings/index/DownloadsIndex.kt | 8 + .../flow/ui/screens/shorts/ShortsScreen.kt | 2 +- app/src/main/res/values/strings.xml | 12 +- .../shared/DownloadSheetChoicesTest.kt | 130 ++++++ .../dialogs/PlayerDialogsContainerTest.kt | 6 +- 22 files changed, 924 insertions(+), 109 deletions(-) create mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/shared/DownloadChoiceTile.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/shared/DownloadSheetChoices.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadPrompt.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadSheet.kt create mode 100644 app/src/test/java/io/github/aedev/flow/ui/components/shared/DownloadSheetChoicesTest.kt diff --git a/app/src/androidTest/java/io/github/aedev/flow/ui/components/shared/DownloadDialogsInstrumentedTest.kt b/app/src/androidTest/java/io/github/aedev/flow/ui/components/shared/DownloadDialogsInstrumentedTest.kt index e67db17ad7..d785d1a587 100644 --- a/app/src/androidTest/java/io/github/aedev/flow/ui/components/shared/DownloadDialogsInstrumentedTest.kt +++ b/app/src/androidTest/java/io/github/aedev/flow/ui/components/shared/DownloadDialogsInstrumentedTest.kt @@ -24,9 +24,9 @@ import org.junit.Rule import org.junit.Test /** - * Device twin of the JVM `DownloadDialogsTest`: the compact download dialog hosts a text field - * inside a Dialog window, which never reaches idle under Robolectric, so the full-versus-compact - * resolution comparison runs here instead. + * The Classic sheet and the compact dialog offer the same resolutions and open on the same subtitle + * track. On a device: the compact dialog hosts a text field inside a Dialog window, which never + * reaches idle under Robolectric. */ class DownloadDialogsInstrumentedTest { @get:Rule @@ -103,7 +103,7 @@ class DownloadDialogsInstrumentedTest { onDismiss = {}, ) } else { - MediaDownloadDialog( + MediaDownloadSheet( streamSizes = emptyMap(), innerTubeVideoFormats = videoFormats, innerTubeAudioFormats = audioFormats, @@ -131,7 +131,7 @@ class DownloadDialogsInstrumentedTest { }.toSet() @Test - fun compactDialogOffersTheSameResolutionsAsTheFullDialog() { + fun compactDialogOffersTheSameResolutionsAsTheSheet() { setDialogs() val fullHeights = renderedHeights() @@ -146,7 +146,7 @@ class DownloadDialogsInstrumentedTest { } @Test - fun bothDialogsShowTheSubtitleRowOpenOnTheLastLanguageChosen() { + fun bothMenusShowTheSubtitleRowOpenOnTheLastLanguageChosen() { runBlocking { PlayerPreferences(context).setLastDownloadSubtitleLanguage("en") } subtitles = listOf(SubtitleOption(url = "https://example.invalid/en", language = "en", label = "English", isAutoGenerated = false)) setDialogs() diff --git a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt index d8e9c36598..7a1cf74855 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt @@ -132,6 +132,7 @@ 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 LAST_DOWNLOAD_TYPE = stringPreferencesKey("last_download_type") val LAST_DOWNLOAD_HEIGHT = intPreferencesKey("last_download_height") val LAST_DOWNLOAD_CODEC = stringPreferencesKey("last_download_codec") @@ -3202,6 +3203,17 @@ class PlayerPreferences( } } + /** Download opens the download menu; off, it downloads the default choice at once. */ + val downloadAskEveryTime: Flow = + context.playerPreferencesDataStore.data + .map { preferences -> preferences[Keys.DOWNLOAD_ASK_EVERY_TIME] ?: true } + + suspend fun setDownloadAskEveryTime(enabled: Boolean) { + context.playerPreferencesDataStore.edit { preferences -> + preferences[Keys.DOWNLOAD_ASK_EVERY_TIME] = 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/ui/MediaNavigation.kt b/app/src/main/java/io/github/aedev/flow/ui/MediaNavigation.kt index 0d73787670..53f2bf9b4e 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/MediaNavigation.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/MediaNavigation.kt @@ -5,6 +5,7 @@ import androidx.navigation.NavController import androidx.navigation.NavHostController import io.github.aedev.flow.data.shorts.queue.ShortsQueueSource import io.github.aedev.flow.ui.components.layout.navigation.MediaNavigator +import io.github.aedev.flow.ui.components.settings.SettingsTarget import io.github.aedev.flow.utils.YouTubeLink internal const val MUSIC_ARTIST_ROUTE_PATTERN = "artist/{channelId}" @@ -79,6 +80,11 @@ internal class FlowMediaNavigator( override fun openMusicPlaylist(playlistId: String) = openCollection(playlistId) + override fun openSettings(target: SettingsTarget) { + beforeNavigate() + navController.navigate("settings?target=${target.encode()}") + } + override fun openEqualizer() { beforeNavigate() if (navController.currentBackStackEntry?.destination?.route == EQUALIZER_ROUTE) return diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/layout/navigation/MediaNavigator.kt b/app/src/main/java/io/github/aedev/flow/ui/components/layout/navigation/MediaNavigator.kt index b2f277e1f6..fab4ec3c86 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/layout/navigation/MediaNavigator.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/layout/navigation/MediaNavigator.kt @@ -2,11 +2,13 @@ package io.github.aedev.flow.ui.components.layout.navigation import androidx.compose.runtime.Stable import androidx.compose.runtime.staticCompositionLocalOf +import io.github.aedev.flow.ui.components.settings.SettingsTarget import io.github.aedev.flow.utils.YouTubeLink /** * The app-wide way to open the page behind a piece of media: its channel, artist, album or playlist, - * the equalizer the players' audio settings link to, and whatever a pasted YouTube link points at. + * the equalizer the players' audio settings link to, a settings page a menu links to, and whatever a + * pasted YouTube link points at. * * Cards and quick action sheets call this directly instead of taking a callback from every screen * that shows them, so one implementation decides how the players get out of the way and how a page @@ -24,6 +26,9 @@ interface MediaNavigator { fun openEqualizer() + /** Opens a settings page, such as where downloads are saved; a host with no settings ignores it. */ + fun openSettings(target: SettingsTarget) = Unit + /** Opens what [link] points at. False when Flow has no page for it, so the caller can say so. */ fun openLink(link: YouTubeLink): Boolean } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/DownloadChoiceTile.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/DownloadChoiceTile.kt new file mode 100644 index 0000000000..56be25bae7 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/DownloadChoiceTile.kt @@ -0,0 +1,106 @@ +package io.github.aedev.flow.ui.components.shared + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonShapes +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +private val TilePadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp) +private val TileMinHeight = 56.dp +private val TileGap = 8.dp +private val GridPadding = 16.dp + +/** + * One choice of the download sheet: an M3 Expressive toggle button, so the checked one fills with + * the primary colour and rounds its corners the way the toggle does on its own. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun DownloadChoiceTile( + title: String, + detail: String?, + badge: String?, + checked: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val haptics = LocalHapticFeedback.current + val themeShapes = MaterialTheme.shapes + ToggleButton( + checked = checked, + onCheckedChange = { + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + onClick() + }, + shapes = ToggleButtonShapes(shape = themeShapes.medium, pressedShape = themeShapes.small, checkedShape = themeShapes.extraLarge), + contentPadding = TilePadding, + modifier = modifier.heightIn(min = TileMinHeight), + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (badge != null) { + val colors = MaterialTheme.colorScheme + MediaTextBadge( + text = badge, + containerColor = if (checked) colors.onPrimary else colors.surfaceContainerHighest, + contentColor = if (checked) colors.primary else colors.onSurfaceVariant, + ) + } + } + if (detail != null) { + Text( + text = detail, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +/** [items] in rows of [columns] equal tiles; a short last row keeps the tile width. */ +@Composable +internal fun DownloadTileGrid( + items: List, + columns: Int, + tile: @Composable (item: T, modifier: Modifier) -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = GridPadding), + verticalArrangement = Arrangement.spacedBy(TileGap), + ) { + items.chunked(columns).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(TileGap)) { + row.forEach { item -> tile(item, Modifier.weight(1f)) } + repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/DownloadLauncher.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/DownloadLauncher.kt index 42ac8c7f96..de860f29de 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/DownloadLauncher.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/DownloadLauncher.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Environment +import android.os.StatFs import android.provider.Settings import android.widget.Toast import androidx.core.content.edit @@ -14,13 +15,25 @@ import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import io.github.aedev.flow.R +import io.github.aedev.flow.data.local.PlayerPreferences +import io.github.aedev.flow.data.local.entity.DownloadFileType import io.github.aedev.flow.data.model.Video import io.github.aedev.flow.data.video.DownloadStreamPolicy +import io.github.aedev.flow.data.video.VideoDownloadManager import io.github.aedev.flow.data.video.downloader.request.DownloadRequest import io.github.aedev.flow.data.video.downloader.request.DownloadSubtitle import io.github.aedev.flow.data.video.downloader.request.toDownloadRequest import io.github.aedev.flow.data.video.downloader.work.DownloadController +import io.github.aedev.flow.data.video.storage.DownloadFiles import io.github.aedev.flow.innertube.models.response.PlayerResponse +import io.github.aedev.flow.utils.PerformanceDispatcher +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +internal data class DownloadSaveFolder( + val path: String, + val freeBytes: Long, +) /** Starts the downloads both download dialogs offer, asking for storage access once on the way. */ internal object DownloadLauncher { @@ -125,9 +138,30 @@ internal object DownloadLauncher { .submit(request, replaceExisting = true) } + /** Where a video download is saved and the space left there, or null when it can't be read. */ + suspend fun videoSaveFolder(context: Context): DownloadSaveFolder? = + withContext(PerformanceDispatcher.diskIO) { + runCatching { + val location = PlayerPreferences(context).downloadLocation.first() + val destination = + EntryPointAccessors + .fromApplication(context.applicationContext, Dependencies::class.java) + .videoDownloadManager() + .resolveDestination(DownloadFileType.VIDEO, location) + val path = + destination.exportTreeUri?.let { DownloadFiles.treePath(it) } + ?: destination.directory.path + .removePrefix(Environment.getExternalStorageDirectory().path) + .trimStart('/') + DownloadSaveFolder(path = path, freeBytes = StatFs(destination.directory.path).availableBytes) + }.getOrNull() + } + @EntryPoint @InstallIn(SingletonComponent::class) interface Dependencies { fun downloadController(): DownloadController + + fun videoDownloadManager(): VideoDownloadManager } } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/DownloadSheetChoices.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/DownloadSheetChoices.kt new file mode 100644 index 0000000000..12b0f11b08 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/DownloadSheetChoices.kt @@ -0,0 +1,73 @@ +package io.github.aedev.flow.ui.components.shared + +import androidx.compose.runtime.Immutable +import io.github.aedev.flow.data.video.DownloadStreamPolicy +import io.github.aedev.flow.innertube.models.response.PlayerResponse.StreamingData.Format + +/** A codec, in SDR or HDR, that one resolution of the download sheet offers. */ +@Immutable +internal data class DownloadFormatOption( + val codecKey: String, + val hdr: Boolean, +) + +/** + * One resolution of the download sheet. [formats] keep the ladder's order, so the first format of an + * option is its highest frame rate. + */ +@Immutable +internal data class DownloadQuality( + val height: Int, + val fps: Int, + val formats: List, +) { + val options: List = + formats.map { DownloadFormatOption(DownloadStreamPolicy.videoCodecKey(it), DownloadStreamPolicy.isHdr(it)) }.distinct() + + fun format(option: DownloadFormatOption): Format? = + formats.firstOrNull { + DownloadStreamPolicy.videoCodecKey(it) == option.codecKey && DownloadStreamPolicy.isHdr(it) == option.hdr + } +} + +/** What the download sheet offers and opens on, kept apart from the sheet so it can be tested. */ +internal object DownloadSheetChoices { + /** [ladder] from [DownloadStreamPolicy.buildDownloadVideoFormats], one entry per resolution, highest first. */ + fun qualities(ladder: List): List = + ladder + .groupBy(DownloadStreamPolicy::videoHeight) + .map { (height, formats) -> DownloadQuality(height, formats.maxOf { it.fps ?: 0 }, formats) } + .sortedByDescending { it.height } + + /** + * The resolution the sheet opens on: the Download quality, else the nearest one below it, else the + * lowest. A [preferredHeight] of 0 or less means no preference, and takes the highest. + */ + fun preselect( + qualities: List, + preferredHeight: Int, + ): DownloadQuality? { + if (preferredHeight <= 0) return qualities.maxByOrNull { it.height } + return qualities.filter { it.height <= preferredHeight }.maxByOrNull { it.height } + ?: qualities.minByOrNull { it.height } + } + + /** The option [quality] downloads in: [current] when it has it, else [preferredCodec] in SDR, else its first. */ + fun option( + quality: DownloadQuality, + current: DownloadFormatOption?, + preferredCodec: String?, + ): DownloadFormatOption? = + quality.options.firstOrNull { it == current } + ?: quality.options.firstOrNull { it.codecKey == preferredCodec && !it.hdr } + ?: quality.options.firstOrNull() + + /** The audio tiles shown before All languages: the original track and the one in the viewer's language. */ + fun primaryAudio( + audio: List, + preferredTrackId: String?, + ): List = + audio + .filter { it.isOriginal || (preferredTrackId != null && it.audioTrack?.id == preferredTrackId) } + .ifEmpty { audio } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadPrompt.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadPrompt.kt new file mode 100644 index 0000000000..791a04557f --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadPrompt.kt @@ -0,0 +1,109 @@ +package io.github.aedev.flow.ui.components.shared + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import io.github.aedev.flow.data.local.DownloadDialogStyle +import io.github.aedev.flow.data.local.PlayerPreferences +import io.github.aedev.flow.data.local.VideoCodec +import io.github.aedev.flow.data.model.Video +import io.github.aedev.flow.data.video.DownloadStreamPolicy +import io.github.aedev.flow.innertube.models.response.PlayerResponse.StreamingData.Format +import io.github.aedev.flow.player.state.SubtitleOption +import kotlinx.coroutines.flow.first + +/** + * What every Download button opens: the menu in the style chosen in settings. With Ask every time + * off nothing opens, and the choice the Classic sheet would open on downloads at once (#1432). + */ +@Composable +fun MediaDownloadPrompt( + streamSizes: Map, + videoFormats: List, + audioFormats: List, + video: Video, + onDismiss: () -> Unit, + subtitles: List = emptyList(), + currentPlayingHeight: Int = 0, +) { + val context = LocalContext.current + val prefs = remember(context) { PlayerPreferences(context) } + val style by prefs.downloadDialogStyle.collectAsState(initial = null) + val askEveryTime by prefs.downloadAskEveryTime.collectAsState(initial = null) + val latestOnDismiss by rememberUpdatedState(onDismiss) + + when { + style == null || askEveryTime == null -> { + Unit + } + + askEveryTime == false -> { + LaunchedEffect(video.id) { + downloadDefaultChoice(context, prefs, video, videoFormats, audioFormats, subtitles) + latestOnDismiss() + } + } + + style == DownloadDialogStyle.COMPACT -> { + MediaDownloadDialogCompact( + streamSizes = streamSizes, + innerTubeVideoFormats = videoFormats, + innerTubeAudioFormats = audioFormats, + video = video, + currentPlayingHeight = currentPlayingHeight, + subtitles = subtitles, + onDismiss = onDismiss, + ) + } + + else -> { + MediaDownloadSheet( + streamSizes = streamSizes, + innerTubeVideoFormats = videoFormats, + innerTubeAudioFormats = audioFormats, + video = video, + subtitles = subtitles, + onDismiss = onDismiss, + ) + } + } +} + +/** The sheet's opening choice, downloaded without the sheet; with no video stream the queue picks. */ +private suspend fun downloadDefaultChoice( + context: Context, + prefs: PlayerPreferences, + video: Video, + videoFormats: List, + audioFormats: List, + subtitles: List, +) { + val qualities = DownloadSheetChoices.qualities(DownloadStreamPolicy.buildDownloadVideoFormats(videoFormats)) + val quality = DownloadSheetChoices.preselect(qualities, prefs.defaultDownloadQuality.first().height) + if (quality == null) { + DownloadLauncher.startDefaultDownload(context, video) + return + } + val codec = + prefs.defaultDownloadCodec + .first() + .takeIf { it != VideoCodec.AUTO } + ?.codecKey + val format = DownloadSheetChoices.option(quality, current = null, preferredCodec = codec)?.let(quality::format) + val subtitleOptions = downloadableSubtitles(subtitles) + startVideo( + context = context, + video = video, + format = format, + audioStreams = DownloadStreamPolicy.buildDownloadAudioFormats(audioFormats), + preferredLang = prefs.preferredAudioLanguage.first(), + subtitleOptions = subtitleOptions, + subtitleChoice = defaultDownloadSubtitle(subtitleOptions, prefs.lastDownloadSubtitleLanguage.first()), + prefs = prefs, + ) +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadSheet.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadSheet.kt new file mode 100644 index 0000000000..1de373b551 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadSheet.kt @@ -0,0 +1,384 @@ +package io.github.aedev.flow.ui.components.shared + +import android.content.Context +import android.text.format.Formatter +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.github.aedev.flow.R +import io.github.aedev.flow.data.local.PlayerPreferences +import io.github.aedev.flow.data.local.VideoCodec +import io.github.aedev.flow.data.local.VideoQuality +import io.github.aedev.flow.data.model.Video +import io.github.aedev.flow.data.video.DownloadStreamPolicy +import io.github.aedev.flow.data.video.downloader.request.DownloadSubtitle +import io.github.aedev.flow.innertube.models.response.PlayerResponse.StreamingData.Format +import io.github.aedev.flow.player.state.SubtitleOption +import io.github.aedev.flow.player.stream.VideoCodecUtils +import io.github.aedev.flow.ui.components.layout.navigation.LocalMediaNavigator +import io.github.aedev.flow.ui.components.settings.SettingsDestination +import io.github.aedev.flow.ui.components.settings.SettingsTarget +import io.github.aedev.flow.ui.components.shared.quickactions.QuickActionsDefaults +import io.github.aedev.flow.ui.components.shared.quickactions.QuickActionsHeader +import io.github.aedev.flow.ui.components.shared.quickactions.QuickActionsSheet + +private val SheetPadding = 16.dp +private val SectionGap = 12.dp +private const val VIDEO_COLUMNS = 3 +private const val AUDIO_COLUMNS = 2 +private val VideoLocationSetting = SettingsTarget(SettingsDestination.DOWNLOADS, highlight = "downloads.video_location") + +/** + * The Classic download menu: every resolution as a tile, the codecs the chosen one comes in, the + * audio-only tracks and the subtitles on one sheet, with one Download button. It opens on the + * Download quality from settings. A video with no stream to offer gets the SABR fallback instead. + */ +@Composable +fun MediaDownloadSheet( + streamSizes: Map, + innerTubeVideoFormats: List, + innerTubeAudioFormats: List, + video: Video, + subtitles: List = emptyList(), + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val prefs = remember(context) { PlayerPreferences(context) } + val preferredLang by prefs.preferredAudioLanguage.collectAsState(initial = "") + val defaultQuality by prefs.defaultDownloadQuality.collectAsState(initial = null) + val defaultCodec by prefs.defaultDownloadCodec.collectAsState(initial = VideoCodec.AUTO) + val lastSubtitleLanguage by prefs.lastDownloadSubtitleLanguage.collectAsState(initial = null) + + val qualities = + remember(innerTubeVideoFormats) { + DownloadSheetChoices.qualities(DownloadStreamPolicy.buildDownloadVideoFormats(innerTubeVideoFormats)) + } + val audioStreams = remember(innerTubeAudioFormats) { DownloadStreamPolicy.buildDownloadAudioFormats(innerTubeAudioFormats) } + val subtitleOptions = remember(subtitles) { downloadableSubtitles(subtitles) } + val preferredCodec = defaultCodec.takeIf { it != VideoCodec.AUTO }?.codecKey + + var chosenHeight by remember(qualities) { mutableStateOf(null) } + var chosenOption by remember(qualities) { mutableStateOf(null) } + var chosenAudio by remember(audioStreams) { mutableStateOf(null) } + var allLanguages by remember(audioStreams) { mutableStateOf(false) } + val primaryAudio = + remember(audioStreams, preferredLang) { + DownloadSheetChoices.primaryAudio(audioStreams, DownloadStreamPolicy.pickAacAudio(audioStreams, preferredLang)?.audioTrack?.id) + } + var subtitleChoice by remember(subtitleOptions, lastSubtitleLanguage) { + mutableStateOf(defaultDownloadSubtitle(subtitleOptions, lastSubtitleLanguage)) + } + + val quality = + qualities.firstOrNull { it.height == chosenHeight } + ?: defaultQuality?.let { DownloadSheetChoices.preselect(qualities, it.height) } + val option = quality?.let { DownloadSheetChoices.option(it, chosenOption, preferredCodec) } + val audioOnly = chosenAudio ?: audioStreams.firstOrNull().takeIf { qualities.isEmpty() } + val hdrLabel = stringResource(R.string.download_quality_hdr) + val navigator = LocalMediaNavigator.current + + QuickActionsSheet(onDismiss = onDismiss, expandFully = true) { sheet -> + DownloadSheetHeader( + video = video, + onChangeFolder = { + sheet.hideThen { + onDismiss() + navigator.openSettings(VideoLocationSetting) + } + }, + ) + + if (qualities.isEmpty() && audioStreams.isEmpty()) { + NoDownloadStreams(onTrySabr = { + DownloadLauncher.startDefaultDownload(context, video) + sheet.close() + }) + return@QuickActionsSheet + } + + if (qualities.isNotEmpty()) { + FlowSectionHeader(text = stringResource(R.string.video)) + DownloadTileGrid(items = qualities, columns = VIDEO_COLUMNS) { item, modifier -> + val tileOption = DownloadSheetChoices.option(item, option, preferredCodec) + DownloadChoiceTile( + title = VideoCodecUtils.qualityLabelWithFrameRate(item.height, item.fps), + detail = + tileOption?.let { + approxDownloadSizeLabel( + streamSizes[VideoCodecUtils.streamSizeKey(item.height, it.codecKey)], + ) + }, + badge = resolutionBadge(item.height)?.let { stringResource(it) }, + checked = audioOnly == null && item == quality, + onClick = { + chosenHeight = item.height + chosenAudio = null + }, + modifier = modifier, + ) + } + if (audioOnly == null && quality != null && quality.options.size > 1 && option != null) { + FlowConnectedToggleGroup( + options = quality.options.map { FlowToggleOption(it, formatOptionLabel(it, hdrLabel)) }, + selected = option, + onSelected = { chosenOption = it }, + modifier = Modifier.padding(start = SheetPadding, end = SheetPadding, top = SectionGap), + ) + } + } + + if (audioStreams.isNotEmpty()) { + FlowSectionHeader(text = stringResource(R.string.ui_audio_only)) + val shownAudio = if (allLanguages) audioStreams else (primaryAudio + listOfNotNull(chosenAudio)).distinct() + DownloadTileGrid(items = shownAudio, columns = AUDIO_COLUMNS) { stream, modifier -> + DownloadChoiceTile( + title = audioTileTitle(stream), + detail = audioTileDetail(stream), + badge = null, + checked = stream == audioOnly, + onClick = { chosenAudio = stream }, + modifier = modifier, + ) + } + if (shownAudio.size < audioStreams.size) { + TextButton(onClick = { allLanguages = true }, modifier = Modifier.padding(start = 4.dp)) { + Text(stringResource(R.string.download_sheet_all_languages)) + } + } + } + + if (audioOnly == null && subtitleOptions.isNotEmpty()) { + Box(modifier = Modifier.padding(start = SheetPadding, end = SheetPadding, top = SectionGap)) { + DownloadSubtitleRow(options = subtitleOptions, selected = subtitleChoice, onSelect = { subtitleChoice = it }) + } + } + + val format = if (quality != null && option != null) quality.format(option) else null + val choiceLabel = + when { + audioOnly != null -> { + audioTileTitle(audioOnly) + } + + quality == null || option == null -> { + "" + } + + else -> { + listOfNotNull( + VideoCodecUtils.qualityLabelWithFrameRate(quality.height, quality.fps), + approxDownloadSizeLabel(streamSizes[VideoCodecUtils.streamSizeKey(quality.height, option.codecKey)]), + ).joinToString(stringResource(R.string.list_separator_dot)) + } + } + DownloadButton( + label = stringResource(R.string.download_sheet_button, choiceLabel), + enabled = audioOnly != null || format != null, + onClick = { + val started = + if (audioOnly != null) { + DownloadLauncher.startAudioOnlyDownload(context, video, audioOnly).also { queued -> + if (queued) { + Toast + .makeText( + context, + context.getString(R.string.ui_started_download, video.title), + Toast.LENGTH_SHORT, + ).show() + } + } + } else { + startVideo(context, video, format, audioStreams, preferredLang, subtitleOptions, subtitleChoice, prefs) + } + if (started) sheet.close() + }, + ) + } +} + +/** Starts the chosen video with the AAC track in the viewer's language; false when there is none. */ +internal fun startVideo( + context: Context, + video: Video, + format: Format?, + audioStreams: List, + preferredLang: String?, + subtitleOptions: List, + subtitleChoice: SubtitleOption?, + prefs: PlayerPreferences, +): Boolean { + format ?: return false + val audio = DownloadStreamPolicy.pickAacAudio(audioStreams, preferredLang) + if (audio == null) { + Toast.makeText(context, context.getString(R.string.download_no_compatible_audio), Toast.LENGTH_LONG).show() + return false + } + val subtitle = subtitleChoice?.toDownloadSubtitle() ?: DownloadSubtitle.OFF.takeIf { subtitleOptions.isNotEmpty() } + DownloadLauncher.startVideoDownload(context, video, format, audio, subtitle = subtitle) + if (subtitleOptions.isNotEmpty()) rememberDownloadSubtitleChoice(prefs, subtitleChoice) + return true +} + +internal fun resolutionBadge(height: Int): Int? = + when { + height >= VideoQuality.Q_2160P.height -> R.string.filter_4k + height >= VideoQuality.Q_1440P.height -> R.string.quality_badge_2k + height >= VideoQuality.Q_1080P.height -> R.string.filter_hd + else -> null + } + +private fun formatOptionLabel( + option: DownloadFormatOption, + hdrLabel: String, +): String { + val codec = VideoCodecUtils.codecLabelFromKey(option.codecKey) + return if (option.hdr) "$codec $hdrLabel" else codec +} + +@Composable +private fun audioTileTitle(stream: Format): String = + "${DownloadStreamPolicy.audioFormatLabel(stream, stringResource(R.string.audio_format_unknown))} " + + "${DownloadStreamPolicy.audioBitrateKbps(stream)} ${stringResource(R.string.kbps)}" + +@Composable +private fun audioTileDetail(stream: Format): String? { + val parts = + listOfNotNull( + approxDownloadSizeLabel(stream.contentLength), + DownloadStreamPolicy.audioLanguageLabel(stream), + stringResource(R.string.audio_track_dubbed).takeUnless { stream.isOriginal }, + ) + return parts.takeIf { it.isNotEmpty() }?.joinToString(stringResource(R.string.list_separator_dot)) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ColumnScope.DownloadButton( + label: String, + enabled: Boolean, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + enabled = enabled, + shapes = ButtonDefaults.shapes(), + modifier = + Modifier + .fillMaxWidth() + .padding(start = SheetPadding, end = SheetPadding, top = SheetPadding) + .height(ButtonDefaults.MediumContainerHeight), + ) { + Icon(imageVector = Icons.Outlined.Download, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) + Spacer(Modifier.width(ButtonDefaults.IconSpacing)) + Text(text = label, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** The video, and the folder its file goes to with the space left there, as one card. */ +@Composable +private fun DownloadSheetHeader( + video: Video, + onChangeFolder: () -> Unit, +) { + val context = LocalContext.current + val folder by produceState(null) { value = DownloadLauncher.videoSaveFolder(context) } + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = SheetPadding), + ) { + Column(modifier = Modifier.padding(top = SectionGap, bottom = 4.dp)) { + QuickActionsHeader(title = video.title, subtitle = video.channelName) { + MediaThumbnail( + videoId = video.id, + thumbnailUrl = video.thumbnailUrl, + width = QuickActionsDefaults.VideoArtworkWidth, + shape = MaterialTheme.shapes.medium, + durationSeconds = video.duration.takeIf { it > 0 }, + ) + } + folder?.let { saveFolder -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = SheetPadding, end = 4.dp), + ) { + Icon( + imageVector = Icons.Outlined.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = + stringResource( + R.string.download_sheet_save_folder, + saveFolder.path, + Formatter.formatShortFileSize(context, saveFolder.freeBytes), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onChangeFolder) { Text(stringResource(R.string.download_sheet_change_folder)) } + } + } + } + } +} + +@Composable +private fun NoDownloadStreams(onTrySabr: () -> Unit) { + Text( + text = stringResource(R.string.no_download_streams), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(SheetPadding), + ) + Button( + onClick = onTrySabr, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = SheetPadding), + ) { + Text(stringResource(R.string.ui_try_sabr_download)) + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsHost.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsHost.kt index 84419d7a6b..72643316e5 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsHost.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsHost.kt @@ -12,18 +12,14 @@ import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.aedev.flow.R -import io.github.aedev.flow.data.local.DownloadDialogStyle -import io.github.aedev.flow.data.local.PlayerPreferences -import io.github.aedev.flow.ui.components.shared.MediaDownloadDialog -import io.github.aedev.flow.ui.components.shared.MediaDownloadDialogCompact +import io.github.aedev.flow.ui.components.shared.MediaDownloadPrompt import kotlinx.coroutines.flow.collectLatest /** - * What the video menus hand off to the shell: the download dialog, in the style chosen in settings, + * What the video menus hand off to the shell: the download menu, in the style chosen in settings, * and each action's confirmation on the app's snackbar with its undo. Call once, in the shell. */ @Composable @@ -32,34 +28,17 @@ fun QuickActionsHost( viewModel: QuickActionsViewModel = sharedQuickActionsViewModel(), ) { val context = LocalContext.current - val preferences = remember(context) { PlayerPreferences(context) } - val dialogStyle by preferences.downloadDialogStyle.collectAsStateWithLifecycle(DownloadDialogStyle.FULL) val pending by viewModel.pendingDownload.collectAsStateWithLifecycle() pending?.let { options -> - when (dialogStyle) { - DownloadDialogStyle.COMPACT -> { - MediaDownloadDialogCompact( - streamSizes = options.streamSizes, - innerTubeVideoFormats = options.videoFormats, - innerTubeAudioFormats = options.audioFormats, - video = options.video, - subtitles = options.subtitles, - onDismiss = viewModel::dismissDownload, - ) - } - - DownloadDialogStyle.FULL -> { - MediaDownloadDialog( - streamSizes = options.streamSizes, - innerTubeVideoFormats = options.videoFormats, - innerTubeAudioFormats = options.audioFormats, - video = options.video, - subtitles = options.subtitles, - onDismiss = viewModel::dismissDownload, - ) - } - } + MediaDownloadPrompt( + streamSizes = options.streamSizes, + videoFormats = options.videoFormats, + audioFormats = options.audioFormats, + video = options.video, + subtitles = options.subtitles, + onDismiss = viewModel::dismissDownload, + ) } val restoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsSheet.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsSheet.kt index 0866d7b9b3..670945da41 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsSheet.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/quickactions/QuickActionsSheet.kt @@ -92,7 +92,8 @@ class QuickActionsSheetController internal constructor( /** * A media item's menu: an M3 modal sheet that opens half way when its content is taller than half - * the screen and expands as it is dragged or scrolled up. Every way out animates the sheet away. + * the screen and expands as it is dragged or scrolled up, or opens in full with [expandFully], for a + * sheet whose last row is what it is for. Every way out animates the sheet away. * [page] names the page on show, so switching pages keeps one sheet and resets its scroll; on a page * ([onBack] set) back returns to the menu instead of closing it. A [LocalFlowSheetPresenter] shows * the same content its own way instead. @@ -103,6 +104,7 @@ fun QuickActionsSheet( onDismiss: () -> Unit, page: Any? = null, onBack: (() -> Unit)? = null, + expandFully: Boolean = false, content: @Composable ColumnScope.(QuickActionsSheetController) -> Unit, ) { val presenter = LocalFlowSheetPresenter.current @@ -110,7 +112,7 @@ fun QuickActionsSheet( PresentedQuickActionsSheet(presenter, onDismiss, page, onBack, content) return } - val sheetState = rememberFlowSheetState(skipPartiallyExpanded = false) + val sheetState = rememberFlowSheetState(skipPartiallyExpanded = expandFully) val scope = rememberCoroutineScope() val latestOnDismiss by rememberUpdatedState(onDismiss) val controller = diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shorts/ShortsReelPageState.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shorts/ShortsReelPageState.kt index 22a48c6cb2..77431067d8 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shorts/ShortsReelPageState.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shorts/ShortsReelPageState.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import io.github.aedev.flow.data.local.DownloadDialogStyle import io.github.aedev.flow.data.local.PlayerPreferences import io.github.aedev.flow.data.local.ShortsPlayerUiMode @@ -38,7 +37,6 @@ internal data class ShortsReelSettings( val customSpeedsEnabled: Boolean, val customSpeedPresetsRaw: String, val speedSliderEnabled: Boolean, - val downloadDialogStyle: DownloadDialogStyle, ) @Stable @@ -83,7 +81,6 @@ internal fun rememberShortsReelSettings(playerPreferences: PlayerPreferences): S val customSpeedsEnabled by playerPreferences.customSpeedsEnabled.collectAsState(initial = false) val customSpeedPresetsRaw by playerPreferences.customSpeedPresets.collectAsState(initial = "") val speedSliderEnabled by playerPreferences.speedSliderEnabled.collectAsState(initial = false) - val downloadDialogStyle by playerPreferences.downloadDialogStyle.collectAsState(initial = DownloadDialogStyle.FULL) return ShortsReelSettings( playbackMode = playbackMode, @@ -95,6 +92,5 @@ internal fun rememberShortsReelSettings(playerPreferences: PlayerPreferences): S customSpeedsEnabled = customSpeedsEnabled, customSpeedPresetsRaw = customSpeedPresetsRaw, speedSliderEnabled = speedSliderEnabled, - downloadDialogStyle = downloadDialogStyle, ) } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shorts/ShortsSettingsSheetState.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shorts/ShortsSettingsSheetState.kt index 482f2f86e4..2360bbea74 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shorts/ShortsSettingsSheetState.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shorts/ShortsSettingsSheetState.kt @@ -6,15 +6,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import io.github.aedev.flow.data.local.DownloadDialogStyle import io.github.aedev.flow.data.model.ShortVideo import io.github.aedev.flow.data.model.Video import io.github.aedev.flow.data.model.toVideo import io.github.aedev.flow.data.shorts.ShortAudioTrack import io.github.aedev.flow.data.shorts.ShortVideoQuality import io.github.aedev.flow.innertube.models.response.PlayerResponse -import io.github.aedev.flow.ui.components.shared.MediaDownloadDialog -import io.github.aedev.flow.ui.components.shared.MediaDownloadDialogCompact +import io.github.aedev.flow.ui.components.shared.MediaDownloadPrompt import io.github.aedev.flow.ui.screens.shorts.ShortsViewModel internal enum class ShortsSettingsPage { @@ -86,28 +84,14 @@ internal class ShortsSettingsSheetState { } @Composable -internal fun ShortsDownloadDialog( - state: ShortsSettingsSheetState, - style: DownloadDialogStyle, -) { +internal fun ShortsDownloadDialog(state: ShortsSettingsSheetState) { val video = state.downloadVideo ?: return if (state.videoFormats.isEmpty()) return - val onDismiss = { state.downloadVideo = null } - if (style == DownloadDialogStyle.COMPACT) { - MediaDownloadDialogCompact( - streamSizes = state.streamSizes, - innerTubeVideoFormats = state.videoFormats, - innerTubeAudioFormats = state.audioFormats, - video = video, - onDismiss = onDismiss, - ) - } else { - MediaDownloadDialog( - streamSizes = state.streamSizes, - innerTubeVideoFormats = state.videoFormats, - innerTubeAudioFormats = state.audioFormats, - video = video, - onDismiss = onDismiss, - ) - } + MediaDownloadPrompt( + streamSizes = state.streamSizes, + videoFormats = state.videoFormats, + audioFormats = state.audioFormats, + video = video, + onDismiss = { state.downloadVideo = null }, + ) } diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/player/dialogs/PlayerDialogsContainer.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/player/dialogs/PlayerDialogsContainer.kt index 2d9e983816..ac59318495 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/player/dialogs/PlayerDialogsContainer.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/player/dialogs/PlayerDialogsContainer.kt @@ -10,8 +10,7 @@ import io.github.aedev.flow.data.model.Video import io.github.aedev.flow.player.EnhancedPlayerManager import io.github.aedev.flow.player.dlna.DlnaCastManager import io.github.aedev.flow.player.state.EnhancedPlayerState -import io.github.aedev.flow.ui.components.shared.MediaDownloadDialog -import io.github.aedev.flow.ui.components.shared.MediaDownloadDialogCompact +import io.github.aedev.flow.ui.components.shared.MediaDownloadPrompt import io.github.aedev.flow.ui.components.videoplayer.DlnaDevicePickerDialog import io.github.aedev.flow.ui.screens.player.VideoPlayerViewModel import io.github.aedev.flow.ui.screens.player.state.PlayerScreenState @@ -36,34 +35,16 @@ internal fun PlayerDialogsContainer( val playerPreferences = prefs.preferences val coroutineScope = rememberCoroutineScope() - // Download Quality Dialog if (screenState.activeSheet == PlayerSheet.Download) { - when (prefs.downloadDialogStyle) { - io.github.aedev.flow.data.local.DownloadDialogStyle.COMPACT -> { - MediaDownloadDialogCompact( - streamSizes = uiState.streamSizes, - innerTubeVideoFormats = uiState.innerTubeVideoFormats, - innerTubeAudioFormats = uiState.innerTubeAudioFormats, - video = video, - currentPlayingHeight = playerState.effectiveQuality, - subtitles = playerState.availableSubtitles, - onDismiss = { screenState.closeSheet() }, - ) - } - - io.github.aedev.flow.data.local.DownloadDialogStyle.FULL -> { - MediaDownloadDialog( - streamSizes = uiState.streamSizes, - innerTubeVideoFormats = uiState.innerTubeVideoFormats, - innerTubeAudioFormats = uiState.innerTubeAudioFormats, - video = video, - subtitles = playerState.availableSubtitles, - onDismiss = { screenState.closeSheet() }, - ) - } - - null -> { } - } + MediaDownloadPrompt( + streamSizes = uiState.streamSizes, + videoFormats = uiState.innerTubeVideoFormats, + audioFormats = uiState.innerTubeAudioFormats, + video = video, + subtitles = playerState.availableSubtitles, + currentPlayingHeight = playerState.effectiveQuality, + onDismiss = { screenState.closeSheet() }, + ) } if (screenState.isSettingsOpen && !hostedInSidePanel) { diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/player/state/VideoPlayerPreferencesState.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/player/state/VideoPlayerPreferencesState.kt index d3e655949d..4aa6cffa53 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/player/state/VideoPlayerPreferencesState.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/player/state/VideoPlayerPreferencesState.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import io.github.aedev.flow.data.local.DEFAULT_SPEED_SWIPE_STEP import io.github.aedev.flow.data.local.DoubleTapSeekZone -import io.github.aedev.flow.data.local.DownloadDialogStyle import io.github.aedev.flow.data.local.GestureOverlayStyle import io.github.aedev.flow.data.local.HomeFeedColumns import io.github.aedev.flow.data.local.PlayerPreferences @@ -55,7 +54,6 @@ internal class VideoPlayerPreferencesState( val relatedCardStyle: PlayerRelatedCardStyle, val feedColumns: HomeFeedColumns, val deArrowEnabled: Boolean, - val downloadDialogStyle: DownloadDialogStyle?, ) @Composable @@ -93,7 +91,6 @@ internal fun rememberVideoPlayerPreferences(context: Context): VideoPlayerPrefer val relatedCardStyle by playerPreferences.playerRelatedCardStyle.collectAsState(initial = PlayerRelatedCardStyle.FULL_WIDTH) val feedColumns by playerPreferences.homeFeedColumns.collectAsState(initial = HomeFeedColumns.AUTO) val deArrowEnabled by playerPreferences.deArrowEnabled.collectAsState(initial = false) - val downloadDialogStyle by playerPreferences.downloadDialogStyle.collectAsState(initial = null) return VideoPlayerPreferencesState( preferences = playerPreferences, @@ -127,6 +124,5 @@ internal fun rememberVideoPlayerPreferences(context: Context): VideoPlayerPrefer relatedCardStyle = relatedCardStyle, feedColumns = feedColumns, deArrowEnabled = deArrowEnabled, - downloadDialogStyle = downloadDialogStyle, ) } 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 1b1d4fc71d..c64df4d603 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 @@ -186,6 +186,7 @@ internal fun DownloadSettingsScreen( stringResource(musicQualityLabel(musicQuality)) } toggleGroup(DownloadsIndex.menuStyle, menuStyles, menuStyle, viewModel::setMenuStyle) + switch(DownloadsIndex.askEveryTime, viewModel.askEveryTime, viewModel::setAskEveryTime) switch(DownloadsIndex.autoDownloadLikes, viewModel.autoDownloadLikes, viewModel::setAutoDownloadLikes) choice(DownloadsIndex.autoDownloadOpened, onClick = { picker = DownloadPicker.AUTO_DOWNLOAD }) { stringResource(autoDownloadLabel(autoDownloadOpened)) 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 0438b3bca6..e7a77a8da5 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 @@ -92,6 +92,7 @@ class DownloadSettingsViewModel val codec = preferences.defaultDownloadCodec.asState(VideoCodec.AUTO) val musicQuality = preferences.musicDownloadQuality.asState(MusicAudioQuality.HIGH) val menuStyle = preferences.downloadDialogStyle.asState(DownloadDialogStyle.FULL) + val askEveryTime = preferences.downloadAskEveryTime.asState(true) val wifiOnly = preferences.downloadOverWifiOnly.asState(false) val autoDownloadLikes = preferences.autoDownloadLikedMusic.asState(false) val autoDownloadOpened = preferences.autoDownloadOpenedVideos.asState(AutoDownloadMode.OFF) @@ -163,6 +164,8 @@ class DownloadSettingsViewModel fun setMenuStyle(value: DownloadDialogStyle) = write { preferences.setDownloadDialogStyle(value) } + fun setAskEveryTime(value: Boolean) = write { preferences.setDownloadAskEveryTime(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/DownloadsIndex.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/DownloadsIndex.kt index f98e7dae83..5d3a8600e1 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 @@ -55,6 +55,13 @@ internal object DownloadsIndex { R.string.settings_section_download_defaults, summary = R.string.download_menu_style_subtitle, ) + val askEveryTime = + entry( + "ask_every_time", + R.string.download_ask_every_time_title, + R.string.settings_section_download_defaults, + summary = R.string.download_ask_every_time_summary, + ) val autoDownloadLikes = entry( "auto_download_likes", @@ -129,6 +136,7 @@ internal object DownloadsIndex { codec, musicQuality, menuStyle, + askEveryTime, autoDownloadLikes, autoDownloadOpened, wifiOnly, diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt index 17a3f5fa5f..cbbeb20f99 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt @@ -297,7 +297,7 @@ fun ShortsScreen( bottomContentPadding = bottomNavOverlayPadding, ) } - ShortsDownloadDialog(state = settingsSheet, style = reelSettings.downloadDialogStyle) + ShortsDownloadDialog(state = settingsSheet) ShortsTopBar( visible = uiState.shorts.isNotEmpty() && !isInPip, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d47d51fcb2..5c0ad78928 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2467,9 +2467,15 @@ Download menu style Choose how the download dialog looks Classic - Full list of every quality and codec + Every quality on one screen Compact Editable title, dropdowns and thread count + Ask every time + When off, Download uses your download quality + Download %1$s + %1$s · %2$s free + Change + All languages Downloading %1$s... HDR Grant storage access to save downloads in public folders (optional) @@ -3789,8 +3795,8 @@ Each backup replaces the previous one in the folder. Choose a folder first export, import, restore, migrate, transfer, takeout, newpipe, libretube, freetube - Quick download quality - Used when you download straight from a video card + Download quality + Selected first when you download Song download quality Used for songs, albums and music playlists Tags for older downloads diff --git a/app/src/test/java/io/github/aedev/flow/ui/components/shared/DownloadSheetChoicesTest.kt b/app/src/test/java/io/github/aedev/flow/ui/components/shared/DownloadSheetChoicesTest.kt new file mode 100644 index 0000000000..ddd7fd3ab1 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/ui/components/shared/DownloadSheetChoicesTest.kt @@ -0,0 +1,130 @@ +package io.github.aedev.flow.ui.components.shared + +import com.google.common.truth.Truth.assertThat +import io.github.aedev.flow.data.video.DownloadStreamPolicy +import io.github.aedev.flow.innertube.models.response.PlayerResponse.StreamingData.Format +import org.junit.Test + +class DownloadSheetChoicesTest { + private fun video( + itag: Int, + codec: String, + height: Int, + fps: Int = 30, + hdr: Boolean = false, + ) = Format( + itag = itag, + url = "https://example.invalid/$itag", + mimeType = + when (codec) { + "av1" -> "video/mp4; codecs=\"av01.0.08M.08\"" + "vp9" -> "video/webm; codecs=\"vp9\"" + else -> "video/mp4; codecs=\"avc1.640028\"" + }, + bitrate = 1_000_000, + width = height * 16 / 9, + height = height, + contentLength = null, + quality = "medium", + fps = fps, + qualityLabel = "${height}p", + averageBitrate = null, + audioQuality = null, + approxDurationMs = null, + audioSampleRate = null, + audioChannels = null, + loudnessDb = null, + lastModified = null, + signatureCipher = null, + colorInfo = if (hdr) Format.ColorInfo(transferCharacteristics = "COLOR_TRANSFER_CHARACTERISTICS_SMPTEST2084") else null, + ) + + private fun qualities(vararg formats: Format) = + DownloadSheetChoices.qualities(DownloadStreamPolicy.buildDownloadVideoFormats(formats.toList())) + + private val ladder = + qualities( + video(401, "av1", 2160), + video(313, "vp9", 2160), + video(399, "av1", 1080, fps = 60), + video(299, "h264", 1080, fps = 60), + video(248, "vp9", 1080), + video(337, "vp9", 1080, hdr = true), + video(136, "h264", 720), + video(135, "h264", 480), + ) + + @Test + fun `one quality per resolution, highest first`() { + assertThat(ladder.map { it.height }).containsExactly(2160, 1080, 720, 480).inOrder() + assertThat(ladder.first { it.height == 1080 }.fps).isEqualTo(60) + } + + @Test + fun `options keep codec priority and put SDR before HDR`() { + val options = ladder.first { it.height == 1080 }.options + assertThat(options) + .containsExactly( + DownloadFormatOption("vp9", hdr = false), + DownloadFormatOption("vp9", hdr = true), + DownloadFormatOption("h264", hdr = false), + DownloadFormatOption("av1", hdr = false), + ).inOrder() + } + + @Test + fun `an option downloads its highest frame rate`() { + val q1080 = ladder.first { it.height == 1080 } + assertThat(q1080.format(DownloadFormatOption("h264", hdr = false))?.itag).isEqualTo(299) + assertThat(q1080.format(DownloadFormatOption("vp9", hdr = true))?.itag).isEqualTo(337) + } + + @Test + fun `the sheet opens on the download quality or the nearest below`() { + assertThat(DownloadSheetChoices.preselect(ladder, 720)?.height).isEqualTo(720) + assertThat(DownloadSheetChoices.preselect(ladder, 1440)?.height).isEqualTo(1080) + assertThat(DownloadSheetChoices.preselect(ladder, 360)?.height).isEqualTo(480) + assertThat(DownloadSheetChoices.preselect(ladder, 0)?.height).isEqualTo(2160) + assertThat(DownloadSheetChoices.preselect(emptyList(), 720)).isNull() + } + + @Test + fun `a resolution keeps the chosen codec, else the default codec in SDR`() { + val q1080 = ladder.first { it.height == 1080 } + val q720 = ladder.first { it.height == 720 } + val av1 = DownloadFormatOption("av1", hdr = false) + assertThat(DownloadSheetChoices.option(q1080, current = av1, preferredCodec = null)).isEqualTo(av1) + assertThat(DownloadSheetChoices.option(q720, current = av1, preferredCodec = "vp9")) + .isEqualTo(DownloadFormatOption("h264", hdr = false)) + assertThat(DownloadSheetChoices.option(q1080, current = null, preferredCodec = "h264")) + .isEqualTo(DownloadFormatOption("h264", hdr = false)) + assertThat(DownloadSheetChoices.option(q1080, current = null, preferredCodec = null)) + .isEqualTo(DownloadFormatOption("vp9", hdr = false)) + } + + private fun audio( + itag: Int, + trackId: String?, + original: Boolean, + ) = video(itag, "h264", 0).copy( + mimeType = "audio/mp4; codecs=\"mp4a.40.2\"", + height = null, + width = null, + audioTrack = trackId?.let { Format.AudioTrack(displayName = it, id = "$it.${if (original) 4 else 3}", audioIsDefault = original) }, + ) + + @Test + fun `audio opens on the original track and the viewer's language`() { + val original = audio(140, "en", original = true) + val arabic = audio(141, "ar", original = false) + val bangla = audio(142, "bn", original = false) + val all = listOf(original, arabic, bangla) + + assertThat(DownloadSheetChoices.primaryAudio(all, preferredTrackId = null)).containsExactly(original) + assertThat( + DownloadSheetChoices.primaryAudio(all, preferredTrackId = arabic.audioTrack?.id), + ).containsExactly(original, arabic).inOrder() + val noTracks = listOf(audio(140, null, original = true), audio(139, null, original = true)) + assertThat(DownloadSheetChoices.primaryAudio(noTracks, preferredTrackId = null)).isEqualTo(noTracks) + } +} diff --git a/app/src/test/java/io/github/aedev/flow/ui/screens/player/dialogs/PlayerDialogsContainerTest.kt b/app/src/test/java/io/github/aedev/flow/ui/screens/player/dialogs/PlayerDialogsContainerTest.kt index a5f52a8bf6..fa94d45efe 100644 --- a/app/src/test/java/io/github/aedev/flow/ui/screens/player/dialogs/PlayerDialogsContainerTest.kt +++ b/app/src/test/java/io/github/aedev/flow/ui/screens/player/dialogs/PlayerDialogsContainerTest.kt @@ -100,14 +100,14 @@ class PlayerDialogsContainerTest { } @Test - fun downloadSheetMountsTheFullDialogWhenPreferred() { + fun downloadSheetMountsTheClassicSheetWhenPreferred() { setDownloadDialogStyle(DownloadDialogStyle.FULL) // No caption tracks: the Subtitles row is a text field, which never idles in a Dialog under // Robolectric. Device twin: DownloadDialogsInstrumentedTest. setContainer(PlayerScreenState().apply { open(PlayerSheet.Download) }, fakePlayerState(availableSubtitles = emptyList())) - waitForText(R.string.download_video) - rule.onNodeWithText(string(R.string.select_quality)).assertExists() + waitForText(R.string.no_download_streams) + rule.onNodeWithText(string(R.string.ui_try_sabr_download)).assertExists() } @Test From 5241eeb87f494e38e890cce31ae0b53e0c431b72 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:04:36 +0200 Subject: [PATCH 5/9] fix(home): launch shows only unseen cached cards and holds the new feed behind a New videos button --- .../flow/ui/screens/home/HomeColdStart.kt | 48 ++++++++++ .../flow/ui/screens/home/HomeFeedBody.kt | 95 +++++++++++++++---- .../aedev/flow/ui/screens/home/HomeUiState.kt | 2 + .../flow/ui/screens/home/HomeViewModel.kt | 89 ++++++++++++++--- app/src/main/res/values/strings.xml | 1 + .../flow/ui/screens/home/HomeColdStartTest.kt | 47 +++++++++ 6 files changed, 247 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/io/github/aedev/flow/ui/screens/home/HomeColdStart.kt create mode 100644 app/src/test/java/io/github/aedev/flow/ui/screens/home/HomeColdStartTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/home/HomeColdStart.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/home/HomeColdStart.kt new file mode 100644 index 0000000000..e1003bed0e --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/home/HomeColdStart.kt @@ -0,0 +1,48 @@ +package io.github.aedev.flow.ui.screens.home + +import androidx.compose.runtime.Immutable +import io.github.aedev.flow.data.model.Video + +/** Fewer cached cards than this is not worth showing at launch: the skeleton waits for the new feed. */ +internal const val MIN_CACHED_CARDS = 6 + +/** The new feed is added after the cached cards once the viewer is this close to their end. */ +internal const val PENDING_APPEND_LOOKAHEAD = 4 + +/** + * The feed wave 1 built while the cached feed was on screen at launch. It never replaces those + * cards on its own (#1447): the New videos button applies it, or it follows the cached cards. + */ +@Immutable +data class HomePendingFeed( + val videos: List