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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -94,7 +94,7 @@ class DownloadDialogsInstrumentedTest {
rule.setContent {
MaterialTheme {
if (showCompact) {
MediaDownloadDialogCompact(
MediaDownloadSheetCompact(
streamSizes = emptyMap(),
innerTubeVideoFormats = videoFormats,
innerTubeAudioFormats = audioFormats,
Expand All @@ -103,7 +103,7 @@ class DownloadDialogsInstrumentedTest {
onDismiss = {},
)
} else {
MediaDownloadDialog(
MediaDownloadSheet(
streamSizes = emptyMap(),
innerTubeVideoFormats = videoFormats,
innerTubeAudioFormats = audioFormats,
Expand Down Expand Up @@ -131,7 +131,7 @@ class DownloadDialogsInstrumentedTest {
}.toSet()

@Test
fun compactDialogOffersTheSameResolutionsAsTheFullDialog() {
fun compactSheetOffersTheSameResolutionsAsTheClassicSheet() {
setDialogs()
val fullHeights = renderedHeights()

Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -1626,6 +1628,17 @@ class PlayerPreferences(
}
}

/** A Play button on headphones may start the last songs after Flow was closed. */
val resumeFromHeadphones: Flow<Boolean> =
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<Boolean> =
context.playerPreferencesDataStore.data
Expand Down Expand Up @@ -3190,6 +3203,17 @@ class PlayerPreferences(
}
}

/** Download opens the download menu; off, it downloads the default choice at once. */
val downloadAskEveryTime: Flow<Boolean> =
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<AutoDownloadMode> =
context.playerPreferencesDataStore.data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1262,6 +1265,7 @@ class Media3MusicService : MediaLibraryService() {
}

private fun stopPlaybackAndService() {
HeadphoneResume.set(this, allowed = false)
retryJobCancel()
waitingForNetwork = false
if (::player.isInitialized) {
Expand Down Expand Up @@ -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),
)
}

Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <T> DownloadTileGrid(
items: List<T>,
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)) }
}
}
}
}
Loading
Loading