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 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/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) 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..dcd5606dc0 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/motion/MusicSideSwipes.kt @@ -0,0 +1,278 @@ +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.foundation.layout.windowInsetsPadding +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.AbsoluteAlignment +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 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 + +/** + * 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: 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 + 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 = 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 = 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/videoplayer/overlay/GestureLevelOverlays.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/GestureLevelOverlays.kt similarity index 89% 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..ba6498bded 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, @@ -217,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 @@ -231,7 +257,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/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/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/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..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 @@ -11,6 +11,10 @@ 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.components.shared.centredGestureHudAlignment import io.github.aedev.flow.ui.screens.player.state.PlayerScreenState private val HudSideInset = 16.dp @@ -34,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 @@ -119,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 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 -} 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 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-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 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 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) + } +} 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