Skip to content
Merged
5 changes: 5 additions & 0 deletions .github/nightly-notes/2026-10-22-feat-music-side-swipes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## New
- Swipe up or down on the left side of the music player to change speed, and on the right side for volume. Turn it on in Settings › Playback › Music (#1403)

## Fixed
- The music player now follows the player Vibration setting
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ class PlayerPreferences(
val MUSIC_REPEAT_MODE = intPreferencesKey("music_repeat_mode")
val PAUSE_MUSIC_WHEN_MUTED = booleanPreferencesKey("pause_music_when_muted")
val MUSIC_RESUME_LONG_TRACKS = booleanPreferencesKey("music_resume_long_tracks")
val MUSIC_SIDE_SWIPES = booleanPreferencesKey("music_side_swipes")
val ANIMATED_ARTWORK_WIFI_ONLY = booleanPreferencesKey("animated_artwork_wifi_only")
val AUTO_DOWNLOAD_LIKED_MUSIC = booleanPreferencesKey("auto_download_liked_music")
val RECAP_SOURCE = stringPreferencesKey("recap_source")
Expand Down Expand Up @@ -2220,6 +2221,18 @@ class PlayerPreferences(
}
}

/** Vertical swipes on the music player's left and right thirds change speed and volume. */
val musicSideSwipes: Flow<Boolean> =
context.playerPreferencesDataStore.data
.map { preferences -> preferences[Keys.MUSIC_SIDE_SWIPES] ?: false }
.distinctUntilChanged()

suspend fun setMusicSideSwipes(enabled: Boolean) {
context.playerPreferencesDataStore.edit { preferences ->
preferences[Keys.MUSIC_SIDE_SWIPES] = enabled
}
}

/** The music player's repeat mode, as Media3's own Player.REPEAT_MODE_* value. */
val musicRepeatMode: Flow<Int> =
context.playerPreferencesDataStore.data
Expand Down
32 changes: 18 additions & 14 deletions app/src/main/java/io/github/aedev/flow/ui/FlowPlayerOverlays.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import io.github.aedev.flow.ui.components.musicplayer.sheet.UnifiedMusicPlayerSh
import io.github.aedev.flow.ui.components.shared.LocalMediaOpenOrigins
import io.github.aedev.flow.ui.components.shared.MediaMiniBarBounds
import io.github.aedev.flow.ui.components.shared.MediaOpenOrigins
import io.github.aedev.flow.ui.components.shared.PlayerHaptics
import io.github.aedev.flow.ui.components.shared.quickactions.QuickActionsHost
import io.github.aedev.flow.ui.components.shared.quickactions.sharedQuickActionsViewModel
import io.github.aedev.flow.ui.components.videoplayer.BackgroundQueueTransport
Expand Down Expand Up @@ -206,6 +207,7 @@ internal fun FlowPlayerOverlays(
val playerPreferences = remember(context) { PlayerPreferences(context) }
val swipeLeftAction by playerPreferences.miniBarSwipeLeftAction.collectAsState(initial = MiniBarSwipeAction.CLOSE)
val swipeRightAction by playerPreferences.miniBarSwipeRightAction.collectAsState(initial = MiniBarSwipeAction.CLOSE)
val playerHaptics by playerPreferences.playerHapticsEnabled.collectAsState(initial = true)
val quickActions = sharedQuickActionsViewModel()
val removedFromQueue = stringResource(R.string.removed_from_queue)
val scope = rememberCoroutineScope()
Expand Down Expand Up @@ -306,20 +308,22 @@ internal fun FlowPlayerOverlays(

val track = currentMusicTrack
if (showMusicSheet && track != null) {
UnifiedMusicPlayerSheet(
state = musicPlayerSheetState,
containerWidth = containerWidth,
containerHeight = containerHeight,
miniBounds = miniBarBounds,
restingBottomPx = { bottomInsets.miniPlayerBaselinePx(density) },
track = track,
swipeLeftAction = swipeLeftAction,
swipeRightAction = swipeRightAction,
onDismiss = {
EnhancedMusicPlayerManager.stop()
EnhancedMusicPlayerManager.clearCurrentTrack()
},
)
PlayerHaptics(enabled = playerHaptics) {
UnifiedMusicPlayerSheet(
state = musicPlayerSheetState,
containerWidth = containerWidth,
containerHeight = containerHeight,
miniBounds = miniBarBounds,
restingBottomPx = { bottomInsets.miniPlayerBaselinePx(density) },
track = track,
swipeLeftAction = swipeLeftAction,
swipeRightAction = swipeRightAction,
onDismiss = {
EnhancedMusicPlayerManager.stop()
EnhancedMusicPlayerManager.clearCurrentTrack()
},
)
}
}

MusicMenuSheets(musicMenus)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ import io.github.aedev.flow.ui.components.musicplayer.controls.PlayerProgressSli
import io.github.aedev.flow.ui.components.musicplayer.lyrics.DEFAULT_LYRICS_TEXT_SIZE
import io.github.aedev.flow.ui.components.musicplayer.lyrics.LARGE_LYRICS_TEXT_SIZE
import io.github.aedev.flow.ui.components.musicplayer.lyrics.lyricsBackdrop
import io.github.aedev.flow.ui.components.musicplayer.motion.MusicSideSwipeHud
import io.github.aedev.flow.ui.components.musicplayer.motion.musicSideSwipes
import io.github.aedev.flow.ui.components.musicplayer.motion.rememberMusicSideSwipes
import io.github.aedev.flow.ui.components.musicplayer.queue.QueueActions
import io.github.aedev.flow.ui.components.musicplayer.queue.QueueList
import io.github.aedev.flow.ui.components.musicplayer.queue.QueuePullUpSheet
Expand Down Expand Up @@ -374,7 +377,8 @@ internal fun FullMusicPlayerContent(
animatedArtwork = animatedArtwork.takeIf { immersiveBackground },
)

val pullUpQueue = Modifier.queuePullUpGesture(queueState, enabled = isPlayerSheetExpanded && !isWide)
val sideSwipes = rememberMusicSideSwipes(layout, isPlayerSheetExpanded) { queueState.fraction() > 0f || showLyricsSheet }
val pullUpQueue = Modifier.musicSideSwipes(sideSwipes).queuePullUpGesture(queueState, enabled = isPlayerSheetExpanded && !isWide)
when (layout) {
MusicPlayerLayout.COMPACT -> {
CompactPlayerLayout(slots, artworkSize, queueState::fraction, navBarPadding, pullUpQueue)
Expand Down Expand Up @@ -433,6 +437,7 @@ internal fun FullMusicPlayerContent(
}
}

MusicSideSwipeHud(sideSwipes)
val queueSheetModifier =
if (layout == MusicPlayerLayout.PORTRAIT_LARGE) {
Modifier.align(Alignment.TopCenter).widthIn(max = PortraitLargeQueueMaxWidth)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,10 @@ internal fun musicPlayerLayoutFor(
!isLandscapeWindow && windowSizeClass.isMediumWidth -> MusicPlayerLayout.PORTRAIT_LARGE
else -> MusicPlayerLayout.COMPACT
}

/**
* Whether the side-column speed and volume swipes apply (#1403). The landscape layouts keep today's
* gestures: SPLIT's control column scrolls, and WIDE has no queue swipe to share the width with.
*/
internal val MusicPlayerLayout.hasSideSwipes: Boolean
get() = this == MusicPlayerLayout.COMPACT || this == MusicPlayerLayout.PORTRAIT_LARGE
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package io.github.aedev.flow.ui.components.musicplayer.motion

import kotlin.math.roundToInt

/** The same split as the fullscreen video gestures, so the columns sit where they do there. */
private const val SIDE_FRACTION = 0.33f

/** Matches the video volume swipe: one swipe across [VOLUME_SENSITIVITY] of the height spans the range. */
private const val VOLUME_SENSITIVITY = 1.5f

/** Which job a vertical swipe on the open music player does, decided where the finger lands. */
internal enum class MusicSwipeZone {
SPEED,
CENTER,
VOLUME,
}

/**
* The zone for a touch at ([x], [y]) on a player [width] wide. The top bar band and the system
* gesture edges stay [MusicSwipeZone.CENTER], so pulling the player down from the top always
* collapses it and a back swipe never turns into a level change.
*/
internal fun musicSwipeZoneAt(
x: Float,
y: Float,
width: Float,
topBandPx: Float,
leftEdgePx: Float,
rightEdgePx: Float,
): MusicSwipeZone =
when {
width <= 0f || y < topBandPx || x < leftEdgePx || x > width - rightEdgePx -> MusicSwipeZone.CENTER
x < width * SIDE_FRACTION -> MusicSwipeZone.SPEED
x > width * (1f - SIDE_FRACTION) -> MusicSwipeZone.VOLUME
else -> MusicSwipeZone.CENTER
}

internal object MusicVolumeSwipe {
/** The volume, 0 to 1, after a swipe from [startLevel] that has travelled [travelUpPx] upwards. */
fun levelFor(
startLevel: Float,
travelUpPx: Float,
heightPx: Float,
): Float {
if (heightPx <= 0f) return startLevel
return (startLevel + travelUpPx / heightPx * VOLUME_SENSITIVITY).coerceIn(0f, 1f)
}

/**
* The stream step for [level]. Rounded rather than floored, so a swipe has to travel half a
* step either way before the volume moves, instead of dropping a step at the first pixel down.
*/
fun stepFor(
level: Float,
maxStep: Int,
): Int = (level * maxStep).roundToInt().coerceIn(0, maxStep.coerceAtLeast(0))
}
Loading
Loading