From 6453de3b45356ca03d1ec344121caf69b2d5e344 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:12:11 +0300 Subject: [PATCH 1/9] refactor(player): move the gesture level HUD to shared components --- .../GestureLevelOverlays.kt | 43 +++++++++++++------ .../overlay/PlayerGestureOverlays.kt | 3 ++ .../player/PlayerAppearancePreviews.kt | 2 +- 3 files changed, 35 insertions(+), 13 deletions(-) rename app/src/main/java/io/github/aedev/flow/ui/components/{videoplayer/overlay => shared}/GestureLevelOverlays.kt (91%) diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/overlay/GestureLevelOverlays.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/GestureLevelOverlays.kt similarity index 91% rename from app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/overlay/GestureLevelOverlays.kt rename to app/src/main/java/io/github/aedev/flow/ui/components/shared/GestureLevelOverlays.kt index dd877e94ee..e6aefe43b7 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/overlay/GestureLevelOverlays.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/GestureLevelOverlays.kt @@ -1,9 +1,24 @@ -package io.github.aedev.flow.ui.components.videoplayer.overlay +package io.github.aedev.flow.ui.components.shared -import androidx.compose.animation.* -import androidx.compose.animation.core.* +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.VolumeDown @@ -14,8 +29,14 @@ import androidx.compose.material.icons.rounded.BrightnessHigh import androidx.compose.material.icons.rounded.BrightnessLow import androidx.compose.material.icons.rounded.BrightnessMedium import androidx.compose.material.icons.rounded.Speed -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -28,8 +49,6 @@ 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.local.GestureOverlayStyle -import io.github.aedev.flow.ui.components.shared.MAX_SLIDER_PLAYBACK_SPEED -import io.github.aedev.flow.ui.components.shared.MIN_PLAYBACK_SPEED import io.github.aedev.flow.ui.theme.PlayerScrimAffordance import io.github.aedev.flow.ui.theme.PlayerScrimContent import io.github.aedev.flow.ui.theme.PlayerScrimGestureHud @@ -48,7 +67,7 @@ private val HudIconSize = 22.dp * existing the moment the gesture ends. */ @Composable -internal fun BrightnessOverlay( +fun BrightnessOverlay( isVisible: Boolean, brightnessLevel: () -> Float, style: GestureOverlayStyle, @@ -90,7 +109,7 @@ internal fun BrightnessOverlay( } @Composable -internal fun VolumeOverlay( +fun VolumeOverlay( isVisible: Boolean, volumeLevel: () -> Float, style: GestureOverlayStyle, @@ -136,7 +155,7 @@ internal fun VolumeOverlay( /** The speed read-out shown mid speed swipe, on the same track as brightness and volume. */ @Composable -internal fun SpeedSwipeOverlay( +fun SpeedSwipeOverlay( isVisible: Boolean, speed: () -> Float, style: GestureOverlayStyle, @@ -231,7 +250,7 @@ private const val VERTICAL_PREVIEW_SCALE = 0.62f * actually draws however these are changed later. */ @Composable -internal fun GestureLevelHudPreview( +fun GestureLevelHudPreview( style: GestureOverlayStyle, modifier: Modifier = Modifier, ) { diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/overlay/PlayerGestureOverlays.kt b/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/overlay/PlayerGestureOverlays.kt index 530a247e92..4ad12ea572 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/overlay/PlayerGestureOverlays.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/overlay/PlayerGestureOverlays.kt @@ -11,6 +11,9 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import io.github.aedev.flow.data.local.GestureOverlayStyle import io.github.aedev.flow.data.local.SpeedSwipeSide +import io.github.aedev.flow.ui.components.shared.BrightnessOverlay +import io.github.aedev.flow.ui.components.shared.SpeedSwipeOverlay +import io.github.aedev.flow.ui.components.shared.VolumeOverlay import io.github.aedev.flow.ui.screens.player.state.PlayerScreenState private val HudSideInset = 16.dp diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearancePreviews.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearancePreviews.kt index 5fbd78abc7..3789f2ae6e 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearancePreviews.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearancePreviews.kt @@ -44,13 +44,13 @@ import io.github.aedev.flow.ui.components.musicplayer.queue.contentColor import io.github.aedev.flow.ui.components.musicplayer.queue.icon import io.github.aedev.flow.ui.components.musicplayer.queue.labelRes import io.github.aedev.flow.ui.components.musicplayer.queue.rowSwipe +import io.github.aedev.flow.ui.components.shared.GestureLevelHudPreview import io.github.aedev.flow.ui.components.shared.MediaMiniBarDefaults import io.github.aedev.flow.ui.components.shared.MediaMiniBarSwipeReveal import io.github.aedev.flow.ui.components.shared.MiniBarReveal import io.github.aedev.flow.ui.components.shared.icon import io.github.aedev.flow.ui.components.shared.isDestructive import io.github.aedev.flow.ui.components.shared.labelRes -import io.github.aedev.flow.ui.components.videoplayer.overlay.GestureLevelHudPreview import io.github.aedev.flow.ui.theme.PlayerGround private const val PREVIEW_PROGRESS = 0.4f From c50b3ba6eb7610189d74f53d3e2ae87a6916c707 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:12:11 +0300 Subject: [PATCH 2/9] refactor(player): let the speed swipe drive any player --- .../gesture => shared}/PlayerSpeedSwipe.kt | 23 ++++++++----------- .../videoplayer/gesture/PlayerDragGestures.kt | 12 ++++++++++ .../PlayerSpeedSwipeTest.kt | 2 +- 3 files changed, 23 insertions(+), 14 deletions(-) rename app/src/main/java/io/github/aedev/flow/ui/components/{videoplayer/gesture => shared}/PlayerSpeedSwipe.kt (82%) rename app/src/test/java/io/github/aedev/flow/ui/components/{videoplayer/gesture => shared}/PlayerSpeedSwipeTest.kt (97%) diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/gesture/PlayerSpeedSwipe.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/PlayerSpeedSwipe.kt similarity index 82% rename from app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/gesture/PlayerSpeedSwipe.kt rename to app/src/main/java/io/github/aedev/flow/ui/components/shared/PlayerSpeedSwipe.kt index 7919509b63..601a46ac06 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/gesture/PlayerSpeedSwipe.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/PlayerSpeedSwipe.kt @@ -1,20 +1,16 @@ -package io.github.aedev.flow.ui.components.videoplayer.gesture +package io.github.aedev.flow.ui.components.shared import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.unit.dp import io.github.aedev.flow.data.local.SpeedSwipeSide -import io.github.aedev.flow.player.EnhancedPlayerManager -import io.github.aedev.flow.ui.components.shared.MAX_SLIDER_PLAYBACK_SPEED -import io.github.aedev.flow.ui.components.shared.MIN_PLAYBACK_SPEED -import io.github.aedev.flow.ui.components.shared.NORMAL_PLAYBACK_SPEED import kotlin.math.ceil import kotlin.math.floor import kotlin.math.round import kotlin.math.truncate /** Vertical travel that moves a speed swipe by one step. */ -internal val SpeedSwipeStepTravel = 16.dp +val SpeedSwipeStepTravel = 16.dp /** Float slack when placing a speed on the step grid. */ private const val GRID_EPSILON = 0.001f @@ -23,7 +19,7 @@ private const val GRID_EPSILON = 0.001f * The speed a vertical swipe on the speed side asks for. Steps land on the step grid, so a swipe from * 1.07x at 0.1 goes to 1.1x and not 1.17x, and the result stays inside the player's speed range. */ -internal object PlayerSpeedSwipe { +object PlayerSpeedSwipe { /** * The speed after a swipe that started at [startSpeed] and has travelled [travelUpPx] upwards * (negative is down), one [step] for every [stepTravelPx]. @@ -63,9 +59,12 @@ internal object PlayerSpeedSwipe { * claiming it ends a held long-press boost, whose release restores the speed underneath first. Live * streams stay at their speed, so a swipe there changes nothing. */ -internal class SpeedSwipeDrag( +class SpeedSwipeDrag( private val haptics: HapticFeedback, private val step: () -> Float, + private val currentSpeed: () -> Float, + private val isLiveStream: () -> Boolean, + private val setSpeed: (Float) -> Unit, private val onSpeed: (Float) -> Unit, private val onEnd: (Float) -> Unit, ) { @@ -75,8 +74,7 @@ internal class SpeedSwipeDrag( private var isLive = false fun begin() { - val manager = EnhancedPlayerManager.getInstance() - isLive = manager.playerState.value.isLive || manager.getPlayer()?.isCurrentMediaItemLive == true + isLive = isLiveStream() } fun drag( @@ -84,9 +82,8 @@ internal class SpeedSwipeDrag( stepTravelPx: Float, ) { if (isLive) return - val manager = EnhancedPlayerManager.getInstance() if (startSpeed.isNaN()) { - startSpeed = manager.getPlayer()?.playbackParameters?.speed ?: manager.playerState.value.playbackSpeed + startSpeed = currentSpeed() lastSpeed = startSpeed travelUpPx = 0f } @@ -106,7 +103,7 @@ internal class SpeedSwipeDrag( travelUpPx = 0f } if (next != lastSpeed) { - manager.setPlaybackSpeed(next) + setSpeed(next) haptics.performHapticFeedback( if (next == NORMAL_PLAYBACK_SPEED) HapticFeedbackType.GestureThresholdActivate else HapticFeedbackType.SegmentFrequentTick, ) diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/gesture/PlayerDragGestures.kt b/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/gesture/PlayerDragGestures.kt index 0df33616af..4ab3f467ad 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/gesture/PlayerDragGestures.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/gesture/PlayerDragGestures.kt @@ -16,6 +16,9 @@ import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.unit.dp import io.github.aedev.flow.data.local.SpeedSwipeSide import io.github.aedev.flow.player.EnhancedPlayerManager +import io.github.aedev.flow.ui.components.shared.PlayerSpeedSwipe +import io.github.aedev.flow.ui.components.shared.SpeedSwipeDrag +import io.github.aedev.flow.ui.components.shared.SpeedSwipeStepTravel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -115,6 +118,15 @@ internal fun Modifier.playerDragGestures( SpeedSwipeDrag( haptics = haptics, step = { currentSpeedSwipeStep }, + currentSpeed = { + val manager = EnhancedPlayerManager.getInstance() + manager.getPlayer()?.playbackParameters?.speed ?: manager.playerState.value.playbackSpeed + }, + isLiveStream = { + val manager = EnhancedPlayerManager.getInstance() + manager.playerState.value.isLive || manager.getPlayer()?.isCurrentMediaItemLive == true + }, + setSpeed = { EnhancedPlayerManager.getInstance().setPlaybackSpeed(it) }, onSpeed = { currentOnSpeedSwipe(it) }, onEnd = { currentOnSpeedSwipeEnd(it) }, ) diff --git a/app/src/test/java/io/github/aedev/flow/ui/components/videoplayer/gesture/PlayerSpeedSwipeTest.kt b/app/src/test/java/io/github/aedev/flow/ui/components/shared/PlayerSpeedSwipeTest.kt similarity index 97% rename from app/src/test/java/io/github/aedev/flow/ui/components/videoplayer/gesture/PlayerSpeedSwipeTest.kt rename to app/src/test/java/io/github/aedev/flow/ui/components/shared/PlayerSpeedSwipeTest.kt index 8660c0991b..282bafdaf1 100644 --- a/app/src/test/java/io/github/aedev/flow/ui/components/videoplayer/gesture/PlayerSpeedSwipeTest.kt +++ b/app/src/test/java/io/github/aedev/flow/ui/components/shared/PlayerSpeedSwipeTest.kt @@ -1,4 +1,4 @@ -package io.github.aedev.flow.ui.components.videoplayer.gesture +package io.github.aedev.flow.ui.components.shared import com.google.common.truth.Truth.assertThat import io.github.aedev.flow.data.local.SpeedSwipeSide From 6f5e25b80d5ff6f4a1384bf5fb0e89580b146447 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:13:17 +0300 Subject: [PATCH 3/9] refactor(player): share the haptics switch used when vibration is off --- .../ui/components/shared/PlayerHaptics.kt | 36 +++++++++++++++++++ .../flow/ui/screens/player/VideoPlayerHost.kt | 22 ++---------- 2 files changed, 38 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/shared/PlayerHaptics.kt diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/PlayerHaptics.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/PlayerHaptics.kt new file mode 100644 index 0000000000..d9134f7f63 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/PlayerHaptics.kt @@ -0,0 +1,36 @@ +package io.github.aedev.flow.ui.components.shared + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback + +/** + * Applies the player's vibration preference to everything in [content]. + * + * Turning the preference off swaps a no-op implementation in over the whole player, so every + * control, gesture and sheet below stops vibrating without a single call site having to ask + * whether it should (#1066). + */ +@Composable +fun PlayerHaptics( + enabled: Boolean, + content: @Composable () -> Unit, +) { + val hostHaptics = LocalHapticFeedback.current + CompositionLocalProvider( + LocalHapticFeedback provides if (enabled) hostHaptics else SilentHapticFeedback, + content = content, + ) +} + +/** + * Stands in for the platform's haptics while the player's vibration preference is off. + * + * Compose ships exactly this as NoHapticFeedback but keeps it internal, and the interface is a + * single method, so there is nothing to reuse and nothing to get wrong. + */ +private object SilentHapticFeedback : HapticFeedback { + override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) = Unit +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/player/VideoPlayerHost.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/player/VideoPlayerHost.kt index 588300ddcf..c79412710b 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/player/VideoPlayerHost.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/player/VideoPlayerHost.kt @@ -9,12 +9,9 @@ import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedback -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -27,6 +24,7 @@ import io.github.aedev.flow.data.model.Video import io.github.aedev.flow.player.EnhancedPlayerManager import io.github.aedev.flow.player.GlobalPlayerState import io.github.aedev.flow.player.dlna.DlnaCastManager +import io.github.aedev.flow.ui.components.shared.PlayerHaptics import io.github.aedev.flow.ui.components.videoplayer.DraggablePlayerLayout import io.github.aedev.flow.ui.components.videoplayer.PlayerDraggableState import io.github.aedev.flow.ui.components.videoplayer.PlayerSheetValue @@ -423,13 +421,7 @@ fun VideoPlayerHost( isFullscreenShown = fullscreenShown, ) - // Turning the preference off swaps Compose's own no-op implementation in over the whole - // player, so every control, gesture and sheet below stops vibrating without a single call - // site having to ask whether it should (#1066). - val hostHaptics = LocalHapticFeedback.current - CompositionLocalProvider( - LocalHapticFeedback provides if (prefs.hapticsEnabled) hostHaptics else SilentHapticFeedback, - ) { + PlayerHaptics(enabled = prefs.hapticsEnabled) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val mediaSheetHeights = rememberMediaSheetHeights( @@ -582,13 +574,3 @@ fun VideoPlayerHost( } } } - -/** - * Stands in for the platform's haptics while the player's vibration preference is off. - * - * Compose ships exactly this as NoHapticFeedback but keeps it internal, and the interface is a - * single method, so there is nothing to reuse and nothing to get wrong. - */ -private object SilentHapticFeedback : HapticFeedback { - override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) = Unit -} From 7c202478864f9a7bc7170d67d87babd6e902d9fb Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:15:47 +0300 Subject: [PATCH 4/9] fix(music): follow the Vibration setting in the music player --- .../aedev/flow/ui/FlowPlayerOverlays.kt | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/ui/FlowPlayerOverlays.kt b/app/src/main/java/io/github/aedev/flow/ui/FlowPlayerOverlays.kt index b31cd53434..579106e977 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/FlowPlayerOverlays.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/FlowPlayerOverlays.kt @@ -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 @@ -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() @@ -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) From 0a21d16365d5ef1cd67a3faf3b0bfadccbaad810 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:19:35 +0300 Subject: [PATCH 5/9] feat(settings): add a switch for speed and volume swipes in the music player --- .../aedev/flow/data/local/PlayerPreferences.kt | 13 +++++++++++++ .../flow/ui/screens/settings/index/PlaybackIndex.kt | 9 +++++++++ .../screens/settings/playback/PlaybackSections.kt | 3 ++- .../settings/playback/PlaybackSettingsScreen.kt | 3 +++ .../settings/playback/PlaybackSettingsViewModel.kt | 3 +++ app/src/main/res/values/strings.xml | 2 ++ 6 files changed, 32 insertions(+), 1 deletion(-) 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 c0c38b4166..083dff1d01 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 @@ -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") @@ -2220,6 +2221,18 @@ class PlayerPreferences( } } + /** Vertical swipes on the music player's left and right thirds change speed and volume. */ + val musicSideSwipes: Flow = + 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 = context.playerPreferencesDataStore.data diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlaybackIndex.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlaybackIndex.kt index a18be551f3..f8f29c84bb 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlaybackIndex.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlaybackIndex.kt @@ -485,6 +485,14 @@ internal object PlaybackIndex { section = R.string.settings_section_music, destination = page, ) + val musicSideSwipes = + SettingEntry( + key = "playback.music_side_swipes", + title = R.string.settings_music_side_swipes_title, + summary = R.string.settings_music_side_swipes_summary, + section = R.string.settings_section_music, + destination = page, + ) val musicVideoSwitch = SettingEntry( key = "playback.music_video_switch", @@ -563,6 +571,7 @@ internal object PlaybackIndex { endlessRadio, pauseMusicWhenMuted, musicResume, + musicSideSwipes, musicVideoSwitch, lyricsProviders, ) diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSections.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSections.kt index d0bb8152ce..a7fac20c20 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSections.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSections.kt @@ -96,7 +96,7 @@ internal fun SettingsListScope.playbackSections( choice(PlaybackIndex.speedSwipe, onClick = { openDialog(PlaybackDialog.SPEED_SWIPE_SIDE) }) { speedSwipeSideLabel(state.speedSwipeSide) } - if (state.speedSwipeSide != SpeedSwipeSide.OFF) { + if (state.speedSwipeSide != SpeedSwipeSide.OFF || state.musicSideSwipes) { choice(PlaybackIndex.speedSwipeStep, onClick = { openDialog(PlaybackDialog.SPEED_SWIPE_STEP) }) { val step by viewModel.speedSwipeStep.collectAsStateWithLifecycle() speedSwipeStepLabel(step) @@ -141,6 +141,7 @@ internal fun SettingsListScope.playbackSections( switch(PlaybackIndex.endlessRadio, viewModel.endlessRadio, viewModel::setEndlessRadio) switch(PlaybackIndex.pauseMusicWhenMuted, viewModel.pauseMusicWhenMuted, viewModel::setPauseMusicWhenMuted) switch(PlaybackIndex.musicResume, viewModel.musicResume, viewModel::setMusicResume) + switch(PlaybackIndex.musicSideSwipes, viewModel.musicSideSwipes, viewModel::setMusicSideSwipes) switch(PlaybackIndex.musicVideoSwitch, viewModel.musicVideoSwitch, viewModel::setMusicVideoSwitch) choice(PlaybackIndex.lyricsProviders, onClick = { openDialog(PlaybackDialog.LYRICS) }) { val providers by viewModel.lyricsProviders.collectAsStateWithLifecycle() diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsScreen.kt index fba01e2a39..ca0b8c5bb9 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsScreen.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsScreen.kt @@ -42,6 +42,7 @@ internal fun PlaybackSettingsScreen( val customSpeeds by viewModel.customSpeeds.collectAsStateWithLifecycle() val doubleTapSeek by viewModel.doubleTapSeek.collectAsStateWithLifecycle() val speedSwipeSide by viewModel.speedSwipeSide.collectAsStateWithLifecycle() + val musicSideSwipes by viewModel.musicSideSwipes.collectAsStateWithLifecycle() val comments by viewModel.comments.collectAsStateWithLifecycle() val shortsContent by viewModel.shortsContent.collectAsStateWithLifecycle() val disableShortsPlayer by viewModel.disableShortsPlayer.collectAsStateWithLifecycle() @@ -60,6 +61,7 @@ internal fun PlaybackSettingsScreen( customSpeeds, doubleTapSeek > 0, speedSwipeSide, + musicSideSwipes, comments, shortsContent, disableShortsPlayer, @@ -80,6 +82,7 @@ internal data class PlaybackStructure( val customSpeeds: Boolean, val doubleTapSeek: Boolean, val speedSwipeSide: SpeedSwipeSide, + val musicSideSwipes: Boolean, val comments: Boolean, val shortsContent: Boolean, val disableShortsPlayer: Boolean, diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsViewModel.kt index 7217731856..642b18ebc1 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsViewModel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsViewModel.kt @@ -102,6 +102,7 @@ class PlaybackSettingsViewModel val musicVideoSwitch = preferences.musicVideoSwitch.asState(false) val pauseMusicWhenMuted = preferences.pauseMusicWhenMuted.asState(false) val musicResume = preferences.musicResumeLongTracks.asState(false) + val musicSideSwipes = preferences.musicSideSwipes.asState(false) val normalizeVolume = preferences.videoVolumeNormalizationEnabled.asState(true) val lyricsProviders = combine(preferences.lyricsProviderOrder, preferences.allLyricsProviderEnabledStates()) { order, enabled -> @@ -240,6 +241,8 @@ class PlaybackSettingsViewModel fun setMusicResume(value: Boolean) = write { preferences.setMusicResumeLongTracks(value) } + fun setMusicSideSwipes(value: Boolean) = write { preferences.setMusicSideSwipes(value) } + fun setNormalizeVolume(value: Boolean) = write { preferences.setVideoVolumeNormalizationEnabled(value) } fun setLyricsProviderEnabled( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 083a9d43e8..484956e4a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2151,6 +2151,8 @@ Resume when the volume comes back Resume podcasts and long tracks Start tracks over 10 minutes where you left off + Speed and volume swipes + Swipe the left side for speed, the right for volume Open the music video in the video player Animated artwork Loop an album\'s motion artwork when it has one From d6255452d03ff0ece89084bbb154fea0c8224a1c Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:29:35 +0300 Subject: [PATCH 6/9] feat(music): swipe the side columns for speed and volume --- .../full/FullMusicPlayerContent.kt | 7 +- .../musicplayer/full/MusicPlayerLayout.kt | 7 + .../musicplayer/motion/MusicSideSwipeMath.kt | 57 ++++ .../musicplayer/motion/MusicSideSwipes.kt | 263 ++++++++++++++++++ .../motion/MusicSideSwipeMathTest.kt | 76 +++++ 5 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipeMath.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipes.kt create mode 100644 app/src/test/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipeMathTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullMusicPlayerContent.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullMusicPlayerContent.kt index bb013e6d90..44acb58208 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullMusicPlayerContent.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullMusicPlayerContent.kt @@ -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 @@ -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) @@ -433,6 +437,7 @@ internal fun FullMusicPlayerContent( } } + MusicSideSwipeHud(sideSwipes) val queueSheetModifier = if (layout == MusicPlayerLayout.PORTRAIT_LARGE) { Modifier.align(Alignment.TopCenter).widthIn(max = PortraitLargeQueueMaxWidth) diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/MusicPlayerLayout.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/MusicPlayerLayout.kt index 80c18ba98c..fd72dff5b7 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/MusicPlayerLayout.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/MusicPlayerLayout.kt @@ -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 diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipeMath.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipeMath.kt new file mode 100644 index 0000000000..1e20503fcc --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipeMath.kt @@ -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)) +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipes.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipes.kt new file mode 100644 index 0000000000..3cdfd62bdd --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipes.kt @@ -0,0 +1,263 @@ +package io.github.aedev.flow.ui.components.musicplayer.motion + +import android.media.AudioManager +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.systemGestures +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import io.github.aedev.flow.data.local.DEFAULT_SPEED_SWIPE_STEP +import io.github.aedev.flow.data.local.GestureOverlayStyle +import io.github.aedev.flow.data.local.PlayerPreferences +import io.github.aedev.flow.player.EnhancedMusicPlayerManager +import io.github.aedev.flow.ui.components.musicplayer.full.MusicPlayerLayout +import io.github.aedev.flow.ui.components.musicplayer.full.hasSideSwipes +import io.github.aedev.flow.ui.components.shared.SpeedSwipeDrag +import io.github.aedev.flow.ui.components.shared.SpeedSwipeOverlay +import io.github.aedev.flow.ui.components.shared.SpeedSwipeStepTravel +import io.github.aedev.flow.ui.components.shared.VolumeOverlay +import kotlin.math.abs + +private val HudEdgePadding = 24.dp + +/** Held open even where the system reports no gesture inset (button navigation, HyperOS), as the video player does. */ +private val MinSideEdgeIgnore = 24.dp + +/** + * Speed and volume swipes on the open music player's side columns (#1403). The middle column keeps + * the queue and collapse swipes; [musicSwipeZoneAt] decides which column a touch belongs to. + */ +internal class MusicSideSwipes( + private val haptics: HapticFeedback, + private val audioManager: AudioManager?, + speedStep: () -> Float, + private val overlayStyle: State, + private val topBandPx: () -> Float, + private val leftEdgePx: () -> Float, + private val rightEdgePx: () -> Float, + private val blocked: () -> Boolean, +) { + var zone by mutableStateOf(MusicSwipeZone.CENTER) + private set + var speed by mutableFloatStateOf(1f) + private set + var volume by mutableFloatStateOf(0f) + private set + val style: GestureOverlayStyle get() = overlayStyle.value + + private val speedDrag = + SpeedSwipeDrag( + haptics = haptics, + step = speedStep, + currentSpeed = { EnhancedMusicPlayerManager.playbackSpeed.value }, + isLiveStream = { false }, + setSpeed = { EnhancedMusicPlayerManager.setPlaybackSpeed(it, persist = false) }, + onSpeed = { speed = it }, + onEnd = { EnhancedMusicPlayerManager.setPlaybackSpeed(it) }, + ) + private var maxVolumeStep = 0 + private var volumeStart = 0f + private var volumeTravelUp = 0f + private var volumeStep = -1 + + fun zoneAt( + position: Offset, + size: IntSize, + ): MusicSwipeZone = + if (blocked()) { + MusicSwipeZone.CENTER + } else { + musicSwipeZoneAt(position.x, position.y, size.width.toFloat(), topBandPx(), leftEdgePx(), rightEdgePx()) + } + + fun begin(zone: MusicSwipeZone) { + when (zone) { + MusicSwipeZone.SPEED -> { + speedDrag.begin() + speed = EnhancedMusicPlayerManager.playbackSpeed.value + } + + MusicSwipeZone.VOLUME -> { + // The stream is the source of truth (#1062): each swipe starts from what it holds now. + maxVolumeStep = audioManager?.getStreamMaxVolume(AudioManager.STREAM_MUSIC) ?: 0 + volumeStep = audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: 0 + volumeStart = if (maxVolumeStep > 0) volumeStep.toFloat() / maxVolumeStep else 0f + volumeTravelUp = 0f + volume = volumeStart + } + + MusicSwipeZone.CENTER -> { + Unit + } + } + this.zone = zone + } + + fun drag( + dy: Float, + heightPx: Float, + stepTravelPx: Float, + ) { + when (zone) { + MusicSwipeZone.SPEED -> speedDrag.drag(dy, stepTravelPx) + MusicSwipeZone.VOLUME -> dragVolume(dy, heightPx) + MusicSwipeZone.CENTER -> Unit + } + } + + fun end() { + if (zone == MusicSwipeZone.SPEED) speedDrag.end() + zone = MusicSwipeZone.CENTER + } + + private fun dragVolume( + dy: Float, + heightPx: Float, + ) { + if (maxVolumeStep <= 0) return + volumeTravelUp -= dy + val level = MusicVolumeSwipe.levelFor(volumeStart, volumeTravelUp, heightPx) + // Travel past either end is not banked, so turning back moves the volume at once. + if (level <= 0f || level >= 1f) { + volumeStart = level + volumeTravelUp = 0f + } + val step = MusicVolumeSwipe.stepFor(level, maxVolumeStep) + if (step != volumeStep) { + audioManager?.setStreamVolume(AudioManager.STREAM_MUSIC, step, 0) + haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) + volumeStep = step + } + volume = level + } +} + +/** Null unless the user turned the swipes on and the player is [expanded] in a [layout] that has them. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun rememberMusicSideSwipes( + layout: MusicPlayerLayout, + expanded: Boolean, + blocked: () -> Boolean, +): MusicSideSwipes? { + val context = LocalContext.current + val preferences = remember(context) { PlayerPreferences(context) } + val enabled by preferences.musicSideSwipes.collectAsState(initial = false) + val step = preferences.speedSwipeStep.collectAsState(initial = DEFAULT_SPEED_SWIPE_STEP) + val style = preferences.gestureOverlayStyle.collectAsState(initial = GestureOverlayStyle.CIRCULAR) + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val topBand = + rememberUpdatedState( + WindowInsets.statusBars.getTop(density) + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }, + ) + val systemGestures = WindowInsets.systemGestures + val minEdge = with(density) { MinSideEdgeIgnore.toPx() } + val leftEdge = rememberUpdatedState(maxOf(systemGestures.getLeft(density, layoutDirection).toFloat(), minEdge)) + val rightEdge = rememberUpdatedState(maxOf(systemGestures.getRight(density, layoutDirection).toFloat(), minEdge)) + val currentBlocked = rememberUpdatedState(blocked) + val haptics = LocalHapticFeedback.current + val swipes = + remember(context, haptics) { + MusicSideSwipes( + haptics = haptics, + audioManager = context.getSystemService(AudioManager::class.java), + speedStep = { step.value }, + overlayStyle = style, + topBandPx = { topBand.value }, + leftEdgePx = { leftEdge.value }, + rightEdgePx = { rightEdge.value }, + blocked = { currentBlocked.value() }, + ) + } + return swipes.takeIf { enabled && expanded && layout.hasSideSwipes } +} + +/** + * Claims vertical swipes that start in a side column. It listens in the Initial pass, ahead of the + * queue pull-up and the artwork underneath, and consumes only once the swipe passes vertical touch + * slop: those handlers and the sheet's collapse drag then see consumed input and stand down. Taps + * and sideways drags are never consumed. + */ +internal fun Modifier.musicSideSwipes(swipes: MusicSideSwipes?): Modifier { + if (swipes == null) return this + return pointerInput(swipes) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val zone = swipes.zoneAt(down.position, size) + if (zone == MusicSwipeZone.CENTER) return@awaitEachGesture + val slop = viewConfiguration.touchSlop + var travel = Offset.Zero + var claimed = false + try { + while (true) { + val change = + awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed || (!claimed && change.isConsumed)) break + val delta = change.positionChange() + if (!claimed) { + travel += delta + if (abs(travel.x) > slop && abs(travel.x) >= abs(travel.y)) break + if (abs(travel.y) <= slop) continue + claimed = true + swipes.begin(zone) + } + change.consume() + swipes.drag(delta.y, size.height.toFloat(), SpeedSwipeStepTravel.toPx()) + } + } finally { + if (claimed) swipes.end() + } + } + } +} + +/** The read-out for a side swipe, placed by the video player's rule: centred, or opposite the thumb when vertical. */ +@Composable +internal fun MusicSideSwipeHud(swipes: MusicSideSwipes?) { + if (swipes == null) return + val vertical = swipes.style == GestureOverlayStyle.VERTICAL + Box(modifier = Modifier.fillMaxSize().padding(horizontal = HudEdgePadding)) { + SpeedSwipeOverlay( + isVisible = swipes.zone == MusicSwipeZone.SPEED, + speed = { swipes.speed }, + style = swipes.style, + modifier = Modifier.align(if (vertical) Alignment.CenterEnd else Alignment.Center), + ) + VolumeOverlay( + isVisible = swipes.zone == MusicSwipeZone.VOLUME, + volumeLevel = { swipes.volume }, + style = swipes.style, + maxVolumeLevel = 1f, + modifier = Modifier.align(if (vertical) Alignment.CenterStart else Alignment.Center), + ) + } +} diff --git a/app/src/test/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipeMathTest.kt b/app/src/test/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipeMathTest.kt new file mode 100644 index 0000000000..13e35c75fa --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipeMathTest.kt @@ -0,0 +1,76 @@ +package io.github.aedev.flow.ui.components.musicplayer.motion + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class MusicSideSwipeMathTest { + private fun zone( + x: Float, + y: Float = 1000f, + width: Float = 1080f, + topBandPx: Float = 250f, + edgePx: Float = 60f, + ) = musicSwipeZoneAt(x, y, width, topBandPx, leftEdgePx = edgePx, rightEdgePx = edgePx) + + @Test + fun `the player splits into thirds`() { + assertThat(zone(x = 200f)).isEqualTo(MusicSwipeZone.SPEED) + assertThat(zone(x = 540f)).isEqualTo(MusicSwipeZone.CENTER) + assertThat(zone(x = 900f)).isEqualTo(MusicSwipeZone.VOLUME) + } + + @Test + fun `the thirds match the video fullscreen split`() { + assertThat(zone(x = 355f)).isEqualTo(MusicSwipeZone.SPEED) + assertThat(zone(x = 360f)).isEqualTo(MusicSwipeZone.CENTER) + assertThat(zone(x = 720f)).isEqualTo(MusicSwipeZone.CENTER) + assertThat(zone(x = 725f)).isEqualTo(MusicSwipeZone.VOLUME) + } + + @Test + fun `the top bar band always collapses`() { + assertThat(zone(x = 200f, y = 249f)).isEqualTo(MusicSwipeZone.CENTER) + assertThat(zone(x = 900f, y = 10f)).isEqualTo(MusicSwipeZone.CENTER) + assertThat(zone(x = 200f, y = 250f)).isEqualTo(MusicSwipeZone.SPEED) + } + + @Test + fun `system gesture edges are left to the back swipe`() { + assertThat(zone(x = 59f)).isEqualTo(MusicSwipeZone.CENTER) + assertThat(zone(x = 1021f)).isEqualTo(MusicSwipeZone.CENTER) + assertThat(zone(x = 61f)).isEqualTo(MusicSwipeZone.SPEED) + assertThat(zone(x = 1019f)).isEqualTo(MusicSwipeZone.VOLUME) + } + + @Test + fun `an unmeasured player has no side columns`() { + assertThat(zone(x = 0f, width = 0f, edgePx = 0f)).isEqualTo(MusicSwipeZone.CENTER) + } + + @Test + fun `volume follows the travel and stays in range`() { + assertThat(MusicVolumeSwipe.levelFor(startLevel = 0.5f, travelUpPx = 0f, heightPx = 1500f)).isEqualTo(0.5f) + assertThat(MusicVolumeSwipe.levelFor(startLevel = 0.5f, travelUpPx = 100f, heightPx = 1500f)).isWithin(1e-6f).of(0.6f) + assertThat(MusicVolumeSwipe.levelFor(startLevel = 0.5f, travelUpPx = -100f, heightPx = 1500f)).isWithin(1e-6f).of(0.4f) + assertThat(MusicVolumeSwipe.levelFor(startLevel = 0.9f, travelUpPx = 1500f, heightPx = 1500f)).isEqualTo(1f) + assertThat(MusicVolumeSwipe.levelFor(startLevel = 0.1f, travelUpPx = -1500f, heightPx = 1500f)).isEqualTo(0f) + assertThat(MusicVolumeSwipe.levelFor(startLevel = 0.3f, travelUpPx = 100f, heightPx = 0f)).isEqualTo(0.3f) + } + + @Test + fun `a step moves only after half a step of travel either way`() { + val start = 7f / 15f + assertThat(MusicVolumeSwipe.stepFor(start, maxStep = 15)).isEqualTo(7) + assertThat(MusicVolumeSwipe.stepFor(start - 0.01f, maxStep = 15)).isEqualTo(7) + assertThat(MusicVolumeSwipe.stepFor(start + 0.01f, maxStep = 15)).isEqualTo(7) + assertThat(MusicVolumeSwipe.stepFor(start - 0.04f, maxStep = 15)).isEqualTo(6) + assertThat(MusicVolumeSwipe.stepFor(start + 0.04f, maxStep = 15)).isEqualTo(8) + } + + @Test + fun `steps stay inside the stream range`() { + assertThat(MusicVolumeSwipe.stepFor(0f, maxStep = 15)).isEqualTo(0) + assertThat(MusicVolumeSwipe.stepFor(1f, maxStep = 15)).isEqualTo(15) + assertThat(MusicVolumeSwipe.stepFor(0.5f, maxStep = 0)).isEqualTo(0) + } +} From 92496a51d2af95286ae55122de5794203e5182b3 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:29:39 +0300 Subject: [PATCH 7/9] docs(nightly): note speed and volume swipes in the music player --- .github/nightly-notes/2026-10-22-feat-music-side-swipes.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .github/nightly-notes/2026-10-22-feat-music-side-swipes.md diff --git a/.github/nightly-notes/2026-10-22-feat-music-side-swipes.md b/.github/nightly-notes/2026-10-22-feat-music-side-swipes.md new file mode 100644 index 0000000000..a45b91015b --- /dev/null +++ b/.github/nightly-notes/2026-10-22-feat-music-side-swipes.md @@ -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 From c3b26406bcbad34c5d34e8e14a18b6fa9d9119c8 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:11:42 +0300 Subject: [PATCH 8/9] fix(music): show the swipe read-out at the top centre like the video player --- .../musicplayer/motion/MusicSideSwipes.kt | 23 +++++++++++++++---- .../components/shared/GestureLevelOverlays.kt | 7 ++++++ .../overlay/PlayerGestureOverlays.kt | 7 ++---- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipes.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipes.kt index 3cdfd62bdd..dcd5606dc0 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipes.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipes.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemGestures +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -20,6 +21,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.ui.AbsoluteAlignment import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -44,9 +46,11 @@ import io.github.aedev.flow.ui.components.shared.SpeedSwipeDrag import io.github.aedev.flow.ui.components.shared.SpeedSwipeOverlay import io.github.aedev.flow.ui.components.shared.SpeedSwipeStepTravel import io.github.aedev.flow.ui.components.shared.VolumeOverlay +import io.github.aedev.flow.ui.components.shared.centredGestureHudAlignment import kotlin.math.abs private val HudEdgePadding = 24.dp +private val HudTopInset = 12.dp /** Held open even where the system reports no gesture inset (button navigation, HyperOS), as the video player does. */ private val MinSideEdgeIgnore = 24.dp @@ -240,24 +244,35 @@ internal fun Modifier.musicSideSwipes(swipes: MusicSideSwipes?): Modifier { } } -/** The read-out for a side swipe, placed by the video player's rule: centred, or opposite the thumb when vertical. */ +/** + * The read-out for a side swipe, placed by the video player's rule: the Vertical bar stands opposite + * the thumb, the others sit at the top centre just under the status bar, or in the middle for the ring. + */ @Composable internal fun MusicSideSwipeHud(swipes: MusicSideSwipes?) { if (swipes == null) return val vertical = swipes.style == GestureOverlayStyle.VERTICAL - Box(modifier = Modifier.fillMaxSize().padding(horizontal = HudEdgePadding)) { + val centred = centredGestureHudAlignment(swipes.style) + val centredModifier = Modifier.padding(top = if (centred == Alignment.TopCenter) HudTopInset else 0.dp) + Box( + modifier = + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars) + .padding(horizontal = HudEdgePadding), + ) { SpeedSwipeOverlay( isVisible = swipes.zone == MusicSwipeZone.SPEED, speed = { swipes.speed }, style = swipes.style, - modifier = Modifier.align(if (vertical) Alignment.CenterEnd else Alignment.Center), + modifier = if (vertical) Modifier.align(AbsoluteAlignment.CenterRight) else Modifier.align(centred).then(centredModifier), ) VolumeOverlay( isVisible = swipes.zone == MusicSwipeZone.VOLUME, volumeLevel = { swipes.volume }, style = swipes.style, maxVolumeLevel = 1f, - modifier = Modifier.align(if (vertical) Alignment.CenterStart else Alignment.Center), + modifier = if (vertical) Modifier.align(AbsoluteAlignment.CenterLeft) else Modifier.align(centred).then(centredModifier), ) } } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/GestureLevelOverlays.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/GestureLevelOverlays.kt index e6aefe43b7..ba6498bded 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/GestureLevelOverlays.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/GestureLevelOverlays.kt @@ -236,6 +236,13 @@ private fun GestureLevelHudContent( } } +/** + * Where a read-out that is not the standing Vertical bar sits. The ring is a badge, not a bar: it + * belongs in the middle of the picture, where the eye is. The others read out at the top. + */ +fun centredGestureHudAlignment(style: GestureOverlayStyle): Alignment = + if (style == GestureOverlayStyle.CIRCULAR) Alignment.Center else Alignment.TopCenter + /** The level every style previews at, so the picker compares shapes rather than numbers. */ private const val PREVIEW_LEVEL = 0.62f private const val PREVIEW_LEVEL_PERCENT = 62 diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/overlay/PlayerGestureOverlays.kt b/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/overlay/PlayerGestureOverlays.kt index 4ad12ea572..df91b73361 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/overlay/PlayerGestureOverlays.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/overlay/PlayerGestureOverlays.kt @@ -14,6 +14,7 @@ import io.github.aedev.flow.data.local.SpeedSwipeSide import io.github.aedev.flow.ui.components.shared.BrightnessOverlay import io.github.aedev.flow.ui.components.shared.SpeedSwipeOverlay import io.github.aedev.flow.ui.components.shared.VolumeOverlay +import io.github.aedev.flow.ui.components.shared.centredGestureHudAlignment import io.github.aedev.flow.ui.screens.player.state.PlayerScreenState private val HudSideInset = 16.dp @@ -37,7 +38,7 @@ fun PlayerGestureOverlays( val isVertical = style == GestureOverlayStyle.VERTICAL val edgeInset = HudSideInset + cutoutEdgeInset(isFullscreen) val topInset = HudTopInset + cutoutTopInset(screenState.isFullscreenPortrait) - val centredAlignment = centredHudAlignment(style) + val centredAlignment = centredGestureHudAlignment(style) // Only a read-out that actually sits on the top edge clears the punch-hole. The ring // reads out in the middle of the picture, nowhere near it. val centredTopInset = if (centredAlignment == Alignment.TopCenter) topInset else HudTopInset @@ -122,10 +123,6 @@ fun PlayerGestureOverlays( } } -/** The ring is a badge, not a bar: it belongs in the middle of the picture, where the eye is. */ -private fun centredHudAlignment(style: GestureOverlayStyle): Alignment = - if (style == GestureOverlayStyle.CIRCULAR) Alignment.Center else Alignment.TopCenter - @Composable internal fun cutoutTopInset(isFullscreenPortrait: Boolean): Dp { if (!isFullscreenPortrait) return 0.dp From 2d811df697e7bb77ac415bce930ebd91544df94d Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:24:20 +0300 Subject: [PATCH 9/9] fix(i18n): restore the placeholder in the Russian segment colour string --- app/src/main/res/values-ru/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2636c2e7b6..341d168767 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -3905,7 +3905,7 @@ непереведенный, автоматический перевод, перевод, оригинальный язык, заголовки, описания Самый острый, использует больше всего данных. разрешение, 1080p, 4K, HD, расход данных - Цвет для %1$ + Цвет для %1$s NewPipe LibreTube FreeTube