diff --git a/.github/nightly-notes/2026-10-26-fix-resume-home-download-sheet.md b/.github/nightly-notes/2026-10-26-fix-resume-home-download-sheet.md new file mode 100644 index 0000000000..56f3ced9f0 --- /dev/null +++ b/.github/nightly-notes/2026-10-26-fix-resume-home-download-sheet.md @@ -0,0 +1,13 @@ +## New +- A new download menu shows every quality, the codecs, audio and subtitles on one screen, and lets you pick the video's audio language (#1382) +- Download sits next to Save and Watch later in the video menu, Share moved to Options (#1432) +- Ask every time setting: turn it off to download at your download quality in one tap (#1432) +- Resume from headphones setting in Player & playback › Music (#1450) + +## Improved +- Both download menus are now sheets, and in the player they open under the video like comments and the description (#1382) +- Settings show a shortcut to allow background activity when Android restricts Flow (#1450) + +## Fixed +- Headphones no longer start music after you close Flow or after a video played (#1450) +- Home no longer swaps the feed at launch: videos you saw are left out and new ones wait behind a New videos button (#1447) 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..6a1c581fe5 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 and compact sheets offer the same resolutions and open on the same subtitle track. On + * a device: the compact sheet hosts a text field inside a modal sheet's window, which never reaches + * idle under Robolectric. */ class DownloadDialogsInstrumentedTest { @get:Rule @@ -94,7 +94,7 @@ class DownloadDialogsInstrumentedTest { rule.setContent { MaterialTheme { if (showCompact) { - MediaDownloadDialogCompact( + MediaDownloadSheetCompact( streamSizes = emptyMap(), innerTubeVideoFormats = videoFormats, innerTubeAudioFormats = audioFormats, @@ -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 compactSheetOffersTheSameResolutionsAsTheClassicSheet() { 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 5cef3659f9..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 @@ -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") @@ -131,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") @@ -1626,6 +1628,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 @@ -3190,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/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? { 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..92189551b8 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/DownloadChoiceTile.kt @@ -0,0 +1,110 @@ +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.ToggleButtonColors +import androidx.compose.material3.ToggleButtonDefaults +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, + colors: ToggleButtonColors = ToggleButtonDefaults.toggleButtonColors(), +) { + 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, + colors = colors, + 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/DownloadSheetFrame.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/DownloadSheetFrame.kt new file mode 100644 index 0000000000..a5c6de845e --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/DownloadSheetFrame.kt @@ -0,0 +1,160 @@ +package io.github.aedev.flow.ui.components.shared + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Download +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.Text +import androidx.compose.material3.ToggleButtonColors +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.github.aedev.flow.ui.components.shared.quickactions.QuickActionsSheet +import io.github.aedev.flow.ui.components.shared.quickactions.QuickActionsSheetController + +internal val DownloadSheetPadding = 16.dp + +/** + * The space a screen keeps for its media sheets, as the video player keeps the space under the + * video: a sheet opened here sits in it at [expandedHeight] like the comments and description do, + * instead of as a modal sheet over the screen, and reports [onProgressChange] so the host can make room. + * [bottomPadding] keeps its last row clear of anything the host draws over its bottom edge. + */ +@Stable +class MediaSheetSlot( + val expandedHeight: Dp, + val collapsedHeight: Dp = 0.dp, + val asSidePanel: Boolean = false, + val bottomPadding: Dp = 0.dp, + val onProgressChange: (Float) -> Unit = {}, +) + +/** + * Both download sheets' container: the menu's modal sheet, or with a [slot] the player's own sheet. + * [footer] stays pinned under the scrolling [content] in the slot, and ends the content in the menu. + */ +@Composable +internal fun DownloadSheetFrame( + title: String, + slot: MediaSheetSlot?, + onDismiss: () -> Unit, + footer: @Composable ColumnScope.(QuickActionsSheetController) -> Unit, + content: @Composable ColumnScope.(QuickActionsSheetController) -> Unit, +) { + if (slot == null) { + QuickActionsSheet(onDismiss = onDismiss, expandFully = true) { sheet -> + FlowSheetHeader(title = title, onClose = sheet::close, showDragHandle = false, dividerAlpha = null) + content(sheet) + footer(sheet) + } + return + } + val sheetState = rememberFlowBottomSheetState() + val latestOnDismiss by rememberUpdatedState(onDismiss) + // The sheet reports its own dismissal once its exit animation ends, so a hand-off replaces it. + var handOff by remember { mutableStateOf<(() -> Unit)?>(null) } + val controller = + remember(sheetState) { + QuickActionsSheetController( + hideThen = { after -> + handOff = after + sheetState.dismiss() + }, + onDismiss = { latestOnDismiss() }, + ) + } + FlowBottomSheet( + onDismiss = { handOff?.invoke() ?: latestOnDismiss() }, + modifier = if (slot.asSidePanel) Modifier.fillMaxSize() else Modifier, + state = sheetState, + expandedHeight = slot.expandedHeight, + collapsedHeight = slot.collapsedHeight, + dismissible = !slot.asSidePanel, + dismissOnOutsideTap = false, + shape = RectangleShape, + containerColor = MaterialTheme.colorScheme.surface, + onProgressChange = slot.onProgressChange, + header = { dragModifier -> + FlowSheetHeader( + inSidePane = slot.asSidePanel, + title = title, + onClose = controller::close, + modifier = dragModifier, + ) + }, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(top = 12.dp), + ) { + content(controller) + } + footer(controller) + Spacer(Modifier.height(DownloadSheetPadding + slot.bottomPadding)) + } +} + +/** The one full-width button a download sheet ends on. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun DownloadSheetButton( + label: String, + enabled: Boolean, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + enabled = enabled, + shapes = ButtonDefaults.shapes(), + modifier = + Modifier + .fillMaxWidth() + .padding(start = DownloadSheetPadding, end = DownloadSheetPadding, top = DownloadSheetPadding) + .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 colours of a download sheet's choices. The player's sheets are the surface colour, which the + * theme also gives the toggle buttons' container, so in a [slot] they take the lowest container instead. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun downloadToggleColors(slot: MediaSheetSlot?): ToggleButtonColors = + if (slot == null) { + ToggleButtonDefaults.toggleButtonColors() + } else { + ToggleButtonDefaults.toggleButtonColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest) + } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/FlowConnectedToggleGroup.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/FlowConnectedToggleGroup.kt index d71f89665b..f2f44e73af 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/FlowConnectedToggleGroup.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/FlowConnectedToggleGroup.kt @@ -13,6 +13,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonColors +import androidx.compose.material3.ToggleButtonDefaults import androidx.compose.material3.ToggleButtonShapes import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -48,6 +50,7 @@ fun FlowConnectedToggleGroup( onSelected: (T) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, + colors: ToggleButtonColors = ToggleButtonDefaults.toggleButtonColors(), ) { val haptics = LocalHapticFeedback.current Row( @@ -63,6 +66,7 @@ fun FlowConnectedToggleGroup( onSelected(option.value) }, shapes = connectedButtonShapes(index = index, count = options.size), + colors = colors, contentPadding = ContentPadding, modifier = Modifier.weight(1f), ) { 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/MediaDownloadPrompt.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadPrompt.kt new file mode 100644 index 0000000000..12b171a3d2 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadPrompt.kt @@ -0,0 +1,116 @@ +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, in [slot] when the + * screen keeps one for its sheets. 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, + slot: MediaSheetSlot? = null, +) { + 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 -> { + MediaDownloadSheetCompact( + streamSizes = streamSizes, + innerTubeVideoFormats = videoFormats, + innerTubeAudioFormats = audioFormats, + video = video, + currentPlayingHeight = currentPlayingHeight, + subtitles = subtitles, + slot = slot, + onDismiss = onDismiss, + ) + } + + else -> { + MediaDownloadSheet( + streamSizes = streamSizes, + innerTubeVideoFormats = videoFormats, + innerTubeAudioFormats = audioFormats, + video = video, + subtitles = subtitles, + slot = slot, + 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, + audio = + DownloadStreamPolicy.pickAacAudio( + DownloadStreamPolicy.buildDownloadAudioFormats(audioFormats), + 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..c921503155 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadSheet.kt @@ -0,0 +1,408 @@ +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.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +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.Folder +import androidx.compose.material3.Button +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 + +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. + * With a [slot] it opens there, under the video, instead of over the screen. + */ +@Composable +fun MediaDownloadSheet( + streamSizes: Map, + innerTubeVideoFormats: List, + innerTubeAudioFormats: List, + video: Video, + subtitles: List = emptyList(), + slot: MediaSheetSlot? = null, + 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 audioTracks = remember(audioStreams) { DownloadStreamPolicy.audioTrackChoices(audioStreams) } + 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 videoAudio by remember(audioStreams, preferredLang) { + mutableStateOf(DownloadStreamPolicy.pickAacAudio(audioStreams, preferredLang)) + } + 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 format = if (quality != null && option != null) quality.format(option) else null + val hdrLabel = stringResource(R.string.download_quality_hdr) + val separator = stringResource(R.string.list_separator_dot) + 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(separator) + } + } + val hasStreams = qualities.isNotEmpty() || audioStreams.isNotEmpty() + val navigator = LocalMediaNavigator.current + val toggleColors = downloadToggleColors(slot) + + DownloadSheetFrame( + title = stringResource(R.string.download), + slot = slot, + onDismiss = onDismiss, + footer = { sheet -> + if (!hasStreams) return@DownloadSheetFrame + DownloadSheetButton( + 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, videoAudio, subtitleOptions, subtitleChoice, prefs) + } + if (started) sheet.close() + }, + ) + }, + ) { sheet -> + DownloadSheetHeader( + video = video, + showVideo = slot == null, + onChangeFolder = { + sheet.hideThen { + onDismiss() + navigator.openSettings(VideoLocationSetting) + } + }, + ) + + if (!hasStreams) { + NoDownloadStreams(onTrySabr = { + DownloadLauncher.startDefaultDownload(context, video) + sheet.close() + }) + return@DownloadSheetFrame + } + + 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, + colors = toggleColors, + ) + } + 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 }, + colors = toggleColors, + modifier = Modifier.padding(start = DownloadSheetPadding, end = DownloadSheetPadding, top = SectionGap), + ) + } + if (audioOnly == null && (audioTracks.size > 1 || subtitleOptions.isNotEmpty())) { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(start = DownloadSheetPadding, end = DownloadSheetPadding, top = SectionGap), + ) { + if (audioTracks.size > 1) { + DownloadAudioTrackRow(tracks = audioTracks, selected = videoAudio, onSelect = { videoAudio = it }) + } + if (subtitleOptions.isNotEmpty()) { + DownloadSubtitleRow(options = subtitleOptions, selected = subtitleChoice, onSelect = { subtitleChoice = it }) + } + } + } + } + + 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, + colors = toggleColors, + ) + } + if (shownAudio.size < audioStreams.size) { + TextButton(onClick = { allLanguages = true }, modifier = Modifier.padding(start = 4.dp)) { + Text(stringResource(R.string.download_sheet_all_languages)) + } + } + } + } +} + +/** Starts the chosen video with [audio], the track picked for it; false when there is none. */ +internal fun startVideo( + context: Context, + video: Video, + format: Format?, + audio: Format?, + subtitleOptions: List, + subtitleChoice: SubtitleOption?, + prefs: PlayerPreferences, +): Boolean { + format ?: return false + 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 +} + +/** The language the video downloads with: the original or one of its dubs. */ +@Composable +internal fun DownloadAudioTrackRow( + tracks: List, + selected: Format?, + onSelect: (Format) -> Unit, +) { + DownloadDropdownRow( + label = stringResource(R.string.audio_track), + value = selected?.let { audioTrackLabel(it) }.orEmpty(), + options = tracks.map { track -> audioTrackLabel(track) to { onSelect(track) } }, + ) +} + +/** YouTube already names the original track "… original"; a track named only by its locale gets the word added. */ +@Composable +private fun audioTrackLabel(track: Format): String { + val original = stringResource(R.string.audio_track_original) + val language = DownloadStreamPolicy.audioLanguageLabel(track) ?: return original + return if (track.isOriginal && !language.contains(original, ignoreCase = true)) { + listOf(language, original).joinToString(stringResource(R.string.list_separator_dot)) + } else { + language + } +} + +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)) +} + +/** The video, and the folder its file goes to with the space left there, as one card. */ +@Composable +private fun DownloadSheetHeader( + video: Video, + showVideo: Boolean, + onChangeFolder: () -> Unit, +) { + val context = LocalContext.current + val folder by produceState(null) { value = DownloadLauncher.videoSaveFolder(context) } + if (!showVideo && folder == null) return + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = DownloadSheetPadding), + ) { + Column(modifier = Modifier.padding(top = SectionGap, bottom = 4.dp)) { + if (showVideo) { + 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 = DownloadSheetPadding, 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(DownloadSheetPadding), + ) + Button( + onClick = onTrySabr, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = DownloadSheetPadding), + ) { + Text(stringResource(R.string.ui_try_sabr_download)) + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadDialogCompact.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadSheetCompact.kt similarity index 50% rename from app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadDialogCompact.kt rename to app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadSheetCompact.kt index 144430d426..cf27647adc 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadDialogCompact.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaDownloadSheetCompact.kt @@ -1,34 +1,23 @@ package io.github.aedev.flow.ui.components.shared import android.widget.Toast -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.MusicNote import androidx.compose.material.icons.outlined.VideoLibrary -import androidx.compose.material3.AlertDialogDefaults -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -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 @@ -65,16 +54,16 @@ private val downloadPrefsScope = CoroutineScope(SupervisorJob() + Dispatchers.IO private const val UNRANKED_CODEC = 99 -@OptIn(ExperimentalMaterial3Api::class) @androidx.annotation.OptIn(UnstableApi::class) @Composable -fun MediaDownloadDialogCompact( +fun MediaDownloadSheetCompact( streamSizes: Map, innerTubeVideoFormats: List = emptyList(), innerTubeAudioFormats: List = emptyList(), video: Video, currentPlayingHeight: Int = 0, subtitles: List = emptyList(), + slot: MediaSheetSlot? = null, onDismiss: () -> Unit, ) { val context = LocalContext.current @@ -165,12 +154,12 @@ fun MediaDownloadDialogCompact( val selectedSizeText = approxDownloadSizeLabel(streamSizes[VideoCodecUtils.streamSizeKey(selectedHeight, selectedCodec)]) - fun confirmDownload() { + fun confirmDownload(): Boolean { val finalTitle = title.trim().ifBlank { video.title } val taggedVideo = video.copy(title = finalTitle) if (isAudioMode) { - val stream = audioStreams.getOrNull(selectedAudioIndex) ?: return - if (!DownloadLauncher.startAudioOnlyDownload(context, taggedVideo, stream, threads)) return + val stream = audioStreams.getOrNull(selectedAudioIndex) ?: return false + if (!DownloadLauncher.startAudioOnlyDownload(context, taggedVideo, stream, threads)) return false Toast .makeText( context, @@ -181,19 +170,18 @@ fun MediaDownloadDialogCompact( prefs.setLastDownloadAudioChoice(audioOptionLabel(stream, audioLabelStrings)) prefs.setDownloadThreads(threads) } - onDismiss() - return + return true } val stream = videoStreams.firstOrNull { DownloadStreamPolicy.videoHeight(it) == selectedHeight && DownloadStreamPolicy.videoCodecKey(it) == selectedCodec - } ?: return + } ?: return false val audio = videoAudio if (audio == null) { Toast.makeText(context, context.getString(R.string.download_no_compatible_audio), Toast.LENGTH_LONG).show() - return + return false } val subtitle = subtitleChoice?.toDownloadSubtitle() ?: DownloadSubtitle.OFF.takeIf { subtitleOptions.isNotEmpty() } DownloadLauncher.startVideoDownload(context, taggedVideo, stream, audio, threads, subtitle) @@ -202,183 +190,142 @@ fun MediaDownloadDialogCompact( prefs.setLastDownloadVideoChoice(selectedHeight, selectedCodec) prefs.setDownloadThreads(threads) } - onDismiss() + return true } - 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, - ), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.download_video), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close)) - } - } + val haptics = LocalHapticFeedback.current + DownloadSheetFrame( + title = stringResource(R.string.download_video), + slot = slot, + onDismiss = onDismiss, + footer = { sheet -> + DownloadSheetButton( + label = stringResource(R.string.download), + enabled = (isAudioMode && hasAudio) || (!isAudioMode && hasVideo && selectedCodec.isNotEmpty()), + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + if (confirmDownload()) sheet.close() + }, + ) + }, + ) { + Column(modifier = Modifier.padding(horizontal = DownloadSheetPadding)) { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text(stringResource(R.string.download_title_label)) }, + leadingIcon = { Icon(Icons.Outlined.Edit, contentDescription = null) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(16.dp)) - OutlinedTextField( - value = title, - onValueChange = { title = it }, - label = { Text(stringResource(R.string.download_title_label)) }, - leadingIcon = { Icon(Icons.Outlined.Edit, contentDescription = null) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), + if (hasVideo && hasAudio) { + FlowConnectedToggleGroup( + options = + listOf( + FlowToggleOption( + value = false, + label = stringResource(R.string.video), + icon = Icons.Outlined.VideoLibrary, + ), + FlowToggleOption( + value = true, + label = stringResource(R.string.download_audio), + icon = Icons.Outlined.MusicNote, + ), + ), + selected = isAudioMode, + onSelected = { isAudioMode = it }, + colors = downloadToggleColors(slot), ) - Spacer(Modifier.height(16.dp)) + } - if (hasVideo && hasAudio) { - FlowConnectedToggleGroup( - options = - listOf( - FlowToggleOption( - value = false, - label = stringResource(R.string.video), - icon = Icons.Outlined.VideoLibrary, - ), - FlowToggleOption( - value = true, - label = stringResource(R.string.download_audio), - icon = Icons.Outlined.MusicNote, - ), - ), - selected = isAudioMode, - onSelected = { isAudioMode = it }, - ) - Spacer(Modifier.height(16.dp)) + if (!isAudioMode && hasVideo) { + DownloadDropdownRow( + label = stringResource(R.string.quality), + value = listOfNotNull("${selectedHeight}p", selectedSizeText).joinToString(separatorDot), + options = + heights.map { h -> + "${h}p" to { selectedHeight = h } + }, + ) + Spacer(Modifier.height(10.dp)) + DownloadDropdownRow( + label = stringResource(R.string.download_format_label), + value = codecOptionLabel(selectedCodec, separatorDot), + options = + codecsForHeight.map { codec -> + codecOptionLabel(codec, separatorDot) to { selectedCodec = codec } + }, + ) + if (audioTracks.size > 1) { + Spacer(Modifier.height(10.dp)) + DownloadAudioTrackRow(tracks = audioTracks, selected = videoAudio, onSelect = { videoAudio = it }) } - - if (!isAudioMode && hasVideo) { - DownloadDropdownRow( - label = stringResource(R.string.quality), - value = listOfNotNull("${selectedHeight}p", selectedSizeText).joinToString(separatorDot), - options = - heights.map { h -> - "${h}p" to { selectedHeight = h } - }, - ) + if (subtitleOptions.isNotEmpty()) { Spacer(Modifier.height(10.dp)) - DownloadDropdownRow( - label = stringResource(R.string.download_format_label), - value = codecOptionLabel(selectedCodec, separatorDot), - options = - codecsForHeight.map { codec -> - codecOptionLabel(codec, separatorDot) to { selectedCodec = codec } - }, - ) - if (audioTracks.size > 1) { - Spacer(Modifier.height(10.dp)) - DownloadDropdownRow( - label = stringResource(R.string.audio_track), - value = videoAudio?.let(DownloadStreamPolicy::audioLanguageLabel).orEmpty(), - options = - audioTracks.map { track -> - DownloadStreamPolicy.audioLanguageLabel(track).orEmpty() to { videoAudio = track } - }, - ) - } - if (subtitleOptions.isNotEmpty()) { - Spacer(Modifier.height(10.dp)) - DownloadSubtitleRow( - options = subtitleOptions, - selected = subtitleChoice, - onSelect = { subtitleChoice = it }, - ) - } - } else if (isAudioMode && hasAudio) { - DownloadDropdownRow( - label = stringResource(R.string.download_audio), - value = audioStreams.getOrNull(selectedAudioIndex)?.let { audioOptionLabel(it, audioLabelStrings) } ?: "", - options = - audioStreams.mapIndexed { index, stream -> - audioOptionLabel(stream, audioLabelStrings) to { selectedAudioIndex = index } - }, - ) - } else { - Text( - text = stringResource(R.string.no_download_streams), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + DownloadSubtitleRow( + options = subtitleOptions, + selected = subtitleChoice, + onSelect = { subtitleChoice = it }, ) } - - Spacer(Modifier.height(16.dp)) - - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.download_threads_label), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f), - ) - val currentThreads = threads.coerceIn(MIN_THREADS, MAX_THREADS) - val haptics = LocalHapticFeedback.current - FilledTonalIconButton( - onClick = { - haptics.performHapticFeedback( - if (currentThreads > MIN_THREADS) HapticFeedbackType.SegmentTick else HapticFeedbackType.Reject, - ) - threads = (currentThreads - 1).coerceAtLeast(MIN_THREADS) + } else if (isAudioMode && hasAudio) { + DownloadDropdownRow( + label = stringResource(R.string.download_audio), + value = audioStreams.getOrNull(selectedAudioIndex)?.let { audioOptionLabel(it, audioLabelStrings) } ?: "", + options = + audioStreams.mapIndexed { index, stream -> + audioOptionLabel(stream, audioLabelStrings) to { selectedAudioIndex = index } }, - enabled = currentThreads > MIN_THREADS, - ) { - Icon(Icons.Default.Remove, contentDescription = stringResource(R.string.download_threads_decrease)) - } - Text( - text = currentThreads.toString(), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp), - ) - FilledTonalIconButton( - onClick = { - haptics.performHapticFeedback( - if (currentThreads < MAX_THREADS) HapticFeedbackType.SegmentTick else HapticFeedbackType.Reject, - ) - threads = (currentThreads + 1).coerceAtMost(MAX_THREADS) - }, - enabled = currentThreads < MAX_THREADS, - ) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.download_threads_increase)) - } - } + ) + } else { + Text( + text = stringResource(R.string.no_download_streams), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - Spacer(Modifier.height(FlowDialogDefaults.ActionsSpacing)) + Spacer(Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.download_threads_label), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + val currentThreads = threads.coerceIn(MIN_THREADS, MAX_THREADS) + FilledTonalIconButton( + onClick = { + haptics.performHapticFeedback( + if (currentThreads > MIN_THREADS) HapticFeedbackType.SegmentTick else HapticFeedbackType.Reject, + ) + threads = (currentThreads - 1).coerceAtLeast(MIN_THREADS) + }, + enabled = currentThreads > MIN_THREADS, ) { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } - Spacer(Modifier.width(8.dp)) - val confirmHaptics = LocalHapticFeedback.current - Button( - onClick = { - confirmHaptics.performHapticFeedback(HapticFeedbackType.Confirm) - confirmDownload() - }, - shapes = ButtonDefaults.shapes(), - enabled = (isAudioMode && hasAudio) || (!isAudioMode && hasVideo && selectedCodec.isNotEmpty()), - ) { Text(stringResource(R.string.download)) } + Icon(Icons.Default.Remove, contentDescription = stringResource(R.string.download_threads_decrease)) + } + Text( + text = currentThreads.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 16.dp), + ) + FilledTonalIconButton( + onClick = { + haptics.performHapticFeedback( + if (currentThreads < MAX_THREADS) HapticFeedbackType.SegmentTick else HapticFeedbackType.Reject, + ) + threads = (currentThreads + 1).coerceAtMost(MAX_THREADS) + }, + enabled = currentThreads < MAX_THREADS, + ) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.download_threads_increase)) } } } 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/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/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..57f05cc435 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,14 @@ 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.components.shared.MediaSheetSlot import io.github.aedev.flow.ui.screens.shorts.ShortsViewModel internal enum class ShortsSettingsPage { @@ -85,29 +84,20 @@ internal class ShortsSettingsSheetState { } } +/** The download sheet [ShortsSettingsSheetState.prepareDownload] readied, opened in [slot] like the reel's other sheets. */ @Composable -internal fun ShortsDownloadDialog( +internal fun ShortsDownloadSheet( state: ShortsSettingsSheetState, - style: DownloadDialogStyle, + slot: MediaSheetSlot, ) { 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, + slot = slot, + onDismiss = { state.downloadVideo = null }, + ) } 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