From 09fe8cc9d31f8590116d5d2b316e9d07a5d68668 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 14 Nov 2026 20:22:32 +0200 Subject: [PATCH] feat(music): mini player progress styles --- .../2026-11-14-feat-mini-progress-styles.md | 3 + .../flow/data/local/PlayerPreferences.kt | 39 ++++ .../musicplayer/mini/MiniPlayerContent.kt | 218 ++++++++++-------- .../musicplayer/mini/MiniPlayerCover.kt | 5 +- .../musicplayer/mini/MiniPlayerLook.kt | 9 +- .../musicplayer/mini/MiniPlayerProgress.kt | 165 +++++++++++++ .../player/PlayerAppearanceMusicMini.kt | 80 ++++++- .../player/PlayerAppearanceViewModel.kt | 7 + .../settings/index/PlayerAppearanceIndex.kt | 16 ++ app/src/main/res/values/strings.xml | 9 + .../data/local/DevicePreferencesSplitTest.kt | 2 +- 11 files changed, 454 insertions(+), 99 deletions(-) create mode 100644 .github/nightly-notes/2026-11-14-feat-mini-progress-styles.md create mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerProgress.kt diff --git a/.github/nightly-notes/2026-11-14-feat-mini-progress-styles.md b/.github/nightly-notes/2026-11-14-feat-mini-progress-styles.md new file mode 100644 index 000000000..799c8267a --- /dev/null +++ b/.github/nightly-notes/2026-11-14-feat-mini-progress-styles.md @@ -0,0 +1,3 @@ +## New + +- Music mini player: show progress as a fill behind the bar, a line around its edge, or a straight or wavy line under the song, with or without the ring around the cover 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 d484808e0..4192c5bb6 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 @@ -148,6 +148,8 @@ class PlayerPreferences( val MINI_COVER_MOTION = stringPreferencesKey("mini_cover_motion") val MINI_COVER_SHAPES = stringPreferencesKey("mini_cover_shapes") val MINI_COVER_PAUSED_SHAPE = stringPreferencesKey("mini_cover_paused_shape") + val MINI_BAR_PROGRESS = stringPreferencesKey("mini_bar_progress") + val MINI_COVER_RING = booleanPreferencesKey("mini_cover_ring") val MUSIC_PLAYER_FOLLOW_LIGHT_THEME = booleanPreferencesKey("music_player_follow_light_theme") val SHORTS_PLAYER_UI_MODE = stringPreferencesKey("shorts_player_ui_mode") val GESTURE_OVERLAY_STYLE = stringPreferencesKey("gesture_overlay_style") @@ -1151,6 +1153,31 @@ class PlayerPreferences( } } + /** How the music mini player's bar shows how far the song is, beside or instead of the cover's ring. */ + val miniBarProgress: Flow = + context.playerPreferencesDataStore.data + .map { preferences -> + runCatching { MiniBarProgress.valueOf(preferences[Keys.MINI_BAR_PROGRESS] ?: MiniBarProgress.NONE.name) } + .getOrDefault(MiniBarProgress.NONE) + } + + suspend fun setMiniBarProgress(progress: MiniBarProgress) { + context.playerPreferencesDataStore.edit { preferences -> + preferences[Keys.MINI_BAR_PROGRESS] = progress.name + } + } + + /** Whether the mini player's cover keeps its progress ring. */ + val miniCoverRing: Flow = + context.playerPreferencesDataStore.data + .map { preferences -> preferences[Keys.MINI_COVER_RING] ?: true } + + suspend fun setMiniCoverRing(enabled: Boolean) { + context.playerPreferencesDataStore.edit { preferences -> + preferences[Keys.MINI_COVER_RING] = enabled + } + } + /** The colors the controls use when artwork colors are off. */ val musicPlainControlColors: Flow = context.playerPreferencesDataStore.data @@ -5143,6 +5170,18 @@ internal fun parsePausedCoverShape(stored: String?): CoverShape? = else -> runCatching { CoverShape.valueOf(stored) }.getOrDefault(CoverShape.CIRCLE) } +/** + * How the music mini player's bar shows progress: a tonal fill behind it, a line around its edge, a + * line along its bottom, a wavy one, or nothing. Stored by name. + */ +enum class MiniBarProgress { + NONE, + FILL, + EDGE, + LINE, + WAVY_LINE, +} + /** When a morphing cover moves: as playback starts, stops and changes track, all the time it plays, or never. Stored by name. */ enum class MiniCoverMotion { ON_PLAY, diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerContent.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerContent.kt index e9201d9fe..a88f424d8 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerContent.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerContent.kt @@ -108,119 +108,149 @@ internal fun MiniPlayerContent( animationsEnabled = animationsEnabled, ) - Row( + val progressColor = MaterialTheme.colorScheme.primary + val progressTrack = MaterialTheme.colorScheme.onSurface + Box( modifier = modifier .fillMaxSize() - .padding(start = 8.dp, end = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + .miniBarProgress(look.barProgress, { ringProgress.value }, progressColor, progressTrack), ) { - // With [onOpen] the cover and titles are one target of their own, beside the transport buttons, - // so a remote reaches each of them; a touch host opens the player from the whole bar instead. Row( modifier = Modifier - .weight(1f) - .then( - if (onOpen == null) { - Modifier - } else { - Modifier - .fillMaxHeight() - .clip(CircleShape) - .clickable(interactionSource = null, indication = ripple(focusRingShape = CircleShape), onClick = onOpen) - }, - ), + .fillMaxSize() + .padding(start = 8.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - MiniPlayerCover( - thumbnailUrl = track.listThumbnailUrl, - trackId = track.videoId, - isPlaying = playerState.isPlaying, - look = look, - animationsEnabled = animationsEnabled, - ringProgress = { ringProgress.value }, - ) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, + // With [onOpen] the cover and titles are one target of their own, beside the transport buttons, + // so a remote reaches each of them; a touch host opens the player from the whole bar instead. + Row( + modifier = + Modifier + .weight(1f) + .then( + if (onOpen == null) { + Modifier + } else { + Modifier + .fillMaxHeight() + .clip(CircleShape) + .clickable( + interactionSource = null, + indication = ripple(focusRingShape = CircleShape), + onClick = onOpen, + ) + }, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - key(track.videoId) { - Text( - text = track.title, - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - color = MaterialTheme.colorScheme.onSurface, - overflow = TextOverflow.Ellipsis, - modifier = if (animationsEnabled) Modifier.titleMarquee() else Modifier, - ) - } - Spacer(modifier = Modifier.height(2.dp)) - val artistLine: @Composable () -> Unit = { - Text( - text = elsewhere?.let { stringResource(R.string.play_on_playing_on, it.name) } ?: track.artist, - style = MaterialTheme.typography.bodySmall, - color = if (elsewhere != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (lyrics != null && elsewhere == null) { - val style = MaterialTheme.typography.labelLarge - val sung = MaterialTheme.colorScheme.onSurface - SyncedLyricLine( - lyrics = lyrics, - isPlaying = playerState.isPlaying, - positionKey = playerState.position / 1000L, - style = style, - sungColor = sung, - unsungColor = sung.copy(alpha = SYNCED_LINE_UNSUNG_ALPHA), - // As tall as a lyric line either way, so the title doesn't move when the artist comes back. - modifier = Modifier.fillMaxWidth().heightIn(min = with(LocalDensity.current) { style.lineHeight.toDp() }), - active = animationsEnabled, - clearInGaps = true, - singleLine = true, - contentAlignment = Alignment.CenterStart, - empty = artistLine, - ) - } else { - artistLine() + MiniPlayerCover( + thumbnailUrl = track.listThumbnailUrl, + trackId = track.videoId, + isPlaying = playerState.isPlaying, + look = look, + animationsEnabled = animationsEnabled, + ringProgress = { ringProgress.value }, + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + key(track.videoId) { + Text( + text = track.title, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + color = MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Ellipsis, + modifier = if (animationsEnabled) Modifier.titleMarquee() else Modifier, + ) + } + Spacer(modifier = Modifier.height(2.dp)) + val artistColor = + if (elsewhere != + null + ) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + val artistLine: @Composable () -> Unit = { + Text( + text = elsewhere?.let { stringResource(R.string.play_on_playing_on, it.name) } ?: track.artist, + style = MaterialTheme.typography.bodySmall, + color = artistColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (lyrics != null && elsewhere == null) { + val style = MaterialTheme.typography.labelLarge + val sung = MaterialTheme.colorScheme.onSurface + SyncedLyricLine( + lyrics = lyrics, + isPlaying = playerState.isPlaying, + positionKey = playerState.position / 1000L, + style = style, + sungColor = sung, + unsungColor = sung.copy(alpha = SYNCED_LINE_UNSUNG_ALPHA), + // As tall as a lyric line either way, so the title doesn't move when the artist comes back. + modifier = Modifier.fillMaxWidth().heightIn(min = with(LocalDensity.current) { style.lineHeight.toDp() }), + active = animationsEnabled, + clearInGaps = true, + singleLine = true, + contentAlignment = Alignment.CenterStart, + empty = artistLine, + ) + } else { + artistLine() + } + if (look.barProgress.isLine()) { + MiniBarProgressLine( + style = look.barProgress, + progress = { ringProgress.value }, + isPlaying = playerState.isPlaying, + animationsEnabled = animationsEnabled, + modifier = Modifier.padding(top = 6.dp), + ) + } } } - } - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (elsewhere != null) PlayOnButton() - if (showPrevious) { - IconButton(onClick = { EnhancedMusicPlayerManager.playPrevious() }) { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (elsewhere != null) PlayOnButton() + if (showPrevious) { + IconButton(onClick = { EnhancedMusicPlayerManager.playPrevious() }) { + Icon( + imageVector = Icons.Rounded.SkipPrevious, + contentDescription = stringResource(R.string.previous), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + MediaPlayPauseButton( + isPlaying = playerState.isPlaying, + isBuffering = playerState.isBuffering && animationsEnabled, + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.VirtualKey) + EnhancedMusicPlayerManager.togglePlayPause() + }, + ) + IconButton(onClick = { EnhancedMusicPlayerManager.playNext() }) { Icon( - imageVector = Icons.Rounded.SkipPrevious, - contentDescription = stringResource(R.string.previous), + imageVector = Icons.Rounded.SkipNext, + contentDescription = stringResource(R.string.next), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - MediaPlayPauseButton( - isPlaying = playerState.isPlaying, - isBuffering = playerState.isBuffering && animationsEnabled, - onClick = { - haptics.performHapticFeedback(HapticFeedbackType.VirtualKey) - EnhancedMusicPlayerManager.togglePlayPause() - }, - ) - IconButton(onClick = { EnhancedMusicPlayerManager.playNext() }) { - Icon( - imageVector = Icons.Rounded.SkipNext, - contentDescription = stringResource(R.string.next), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } } } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerCover.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerCover.kt index d07adf574..de1007324 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerCover.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerCover.kt @@ -109,7 +109,7 @@ internal fun MiniPlayerCover( val ringTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.10f) val ringFillColor = MaterialTheme.colorScheme.primary if (look.coverShape == MiniCoverShape.CIRCLE) { - CircleCover(thumbnailUrl, isPlaying, animationsEnabled, ringProgress, ringTrackColor, ringFillColor, modifier) + CircleCover(thumbnailUrl, isPlaying, animationsEnabled, look.coverRing, ringProgress, ringTrackColor, ringFillColor, modifier) } else { MorphingCover(thumbnailUrl, trackId, isPlaying, look, animationsEnabled, ringProgress, ringTrackColor, ringFillColor, modifier) } @@ -120,6 +120,7 @@ private fun CircleCover( thumbnailUrl: String?, isPlaying: Boolean, animationsEnabled: Boolean, + showRing: Boolean, ringProgress: () -> Float, ringTrackColor: Color, ringFillColor: Color, @@ -130,6 +131,7 @@ private fun CircleCover( modifier .size(ArtworkRingSize) .drawBehind { + if (!showRing) return@drawBehind val stroke = ProgressRingStroke.toPx() val inset = stroke / 2f val arcSize = Size(size.width - stroke, size.height - stroke) @@ -315,6 +317,7 @@ private fun MorphingCover( // The fill is revealed by a wedge from 12 o'clock: a path's start point moves between shapes. val wedgeBounds = Rect(size.center, size.maxDimension) onDrawBehind { + if (!look.coverRing) return@onDrawBehind shapeAt(outline, matrix, size.minDimension - stroke, stroke / 2f, center) drawPath(outline, ringTrackColor, style = style) val progress = ringProgress() diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerLook.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerLook.kt index 047dd5829..9bf06adf9 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerLook.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerLook.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import io.github.aedev.flow.data.local.CoverShape import io.github.aedev.flow.data.local.DefaultCoverShapes +import io.github.aedev.flow.data.local.MiniBarProgress import io.github.aedev.flow.data.local.MiniCoverMotion import io.github.aedev.flow.data.local.MiniCoverShape import io.github.aedev.flow.data.local.PlayerPreferences @@ -19,6 +20,8 @@ internal data class MiniPlayerLook( val coverMotion: MiniCoverMotion = MiniCoverMotion.ON_PLAY, val coverShapes: List = DefaultCoverShapes, val pausedShape: CoverShape? = CoverShape.CIRCLE, + val barProgress: MiniBarProgress = MiniBarProgress.NONE, + val coverRing: Boolean = true, ) { companion object { /** Today's bar, for hosts with no choices of their own, such as TV. */ @@ -35,7 +38,9 @@ internal fun rememberMiniPlayerLook(): MiniPlayerLook { val coverMotion by preferences.miniCoverMotion.collectAsState(initial = MiniCoverMotion.ON_PLAY) val coverShapes by preferences.miniCoverShapes.collectAsState(initial = DefaultCoverShapes) val pausedShape by preferences.miniCoverPausedShape.collectAsState(initial = CoverShape.CIRCLE) - return remember(coverShape, coverMotion, coverShapes, pausedShape) { - MiniPlayerLook(coverShape, coverMotion, coverShapes, pausedShape) + val barProgress by preferences.miniBarProgress.collectAsState(initial = MiniBarProgress.NONE) + val coverRing by preferences.miniCoverRing.collectAsState(initial = true) + return remember(coverShape, coverMotion, coverShapes, pausedShape, barProgress, coverRing) { + MiniPlayerLook(coverShape, coverMotion, coverShapes, pausedShape, barProgress, coverRing) } } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerProgress.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerProgress.kt new file mode 100644 index 000000000..55e9d5065 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/mini/MiniPlayerProgress.kt @@ -0,0 +1,165 @@ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + +package io.github.aedev.flow.ui.components.musicplayer.mini + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.WavyProgressIndicatorDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathMeasure +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import io.github.aedev.flow.data.local.MiniBarProgress +import kotlinx.coroutines.delay + +private const val FILL_ALPHA = 0.22f +private const val TRACK_ALPHA = 0.12f +private val EdgeStroke = 3.dp + +private val LineStroke = 3.dp +private val WavyLineHeight = 8.dp + +/** M3's wavy indicator snaps its phase to 0 when its speed does, so the speed waits until the wave is flat. */ +private const val WAVE_FLATTEN_MILLIS = 500L + +/** + * The part of a mini bar's progress drawn behind its content: a tonal [MiniBarProgress.FILL] that + * grows from the start edge, or an [MiniBarProgress.EDGE] line that runs round the pill from its top + * centre. Both read [progress] in draw, so a step repaints the bar without recomposing it. + */ +internal fun Modifier.miniBarProgress( + style: MiniBarProgress, + progress: () -> Float, + color: Color, + trackColor: Color, +): Modifier = + when (style) { + MiniBarProgress.FILL -> { + drawWithCache { + val fill = color.copy(alpha = FILL_ALPHA) + onDrawBehind { + val width = size.width * progress().coerceIn(0f, 1f) + val left = if (layoutDirection == LayoutDirection.Rtl) size.width - width else 0f + drawRect(fill, topLeft = Offset(left, 0f), size = size.copy(width = width)) + } + } + } + + MiniBarProgress.EDGE -> { + drawWithCache { + val strokePx = EdgeStroke.toPx() + val outline = pillOutline(size.width, size.height, strokePx / 2f) + val measure = PathMeasure().apply { setPath(outline, false) } + val played = Path() + val stroke = Stroke(width = strokePx, cap = StrokeCap.Round) + val track = trackColor.copy(alpha = TRACK_ALPHA) + onDrawBehind { + // Clockwise from the top centre, or anticlockwise where text runs right to left. + scale(if (layoutDirection == LayoutDirection.Rtl) -1f else 1f, 1f) { + drawPath(outline, track, style = stroke) + played.rewind() + measure.getSegment(0f, measure.length * progress().coerceIn(0f, 1f), played, true) + drawPath(played, color, style = stroke) + } + } + } + } + + else -> { + this + } + } + +/** A pill's outline [inset] inside a [width] by [height] box, starting at the top centre and running clockwise. */ +private fun pillOutline( + width: Float, + height: Float, + inset: Float, +): Path { + val radius = height / 2f - inset + return Path().apply { + moveTo(width / 2f, inset) + lineTo(width - height / 2f, inset) + arcTo(Rect(Offset(width - height / 2f, height / 2f), radius), -90f, 180f, false) + lineTo(height / 2f, height - inset) + arcTo(Rect(Offset(height / 2f, height / 2f), radius), 90f, 180f, false) + close() + } +} + +/** + * The line styles, under the song's title and artist: Material's linear indicator, or its wavy one, + * which moves only while music plays and the bar is shown. + */ +@Composable +internal fun MiniBarProgressLine( + style: MiniBarProgress, + progress: () -> Float, + isPlaying: Boolean, + animationsEnabled: Boolean, + modifier: Modifier = Modifier, +) { + val color = MaterialTheme.colorScheme.primary + val trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = TRACK_ALPHA) + val line = modifier.fillMaxWidth().clearAndSetSemantics {} + when (style) { + MiniBarProgress.LINE -> { + LinearProgressIndicator( + progress = progress, + modifier = line.height(LineStroke), + color = color, + trackColor = trackColor, + strokeCap = StrokeCap.Round, + drawStopIndicator = {}, + ) + } + + MiniBarProgress.WAVY_LINE -> { + val waving = isPlaying && animationsEnabled + var moving by remember { mutableStateOf(waving) } + LaunchedEffect(waving) { + if (!waving) delay(WAVE_FLATTEN_MILLIS) + moving = waving + } + val stroke = with(LocalDensity.current) { Stroke(width = LineStroke.toPx(), cap = StrokeCap.Round) } + LinearWavyProgressIndicator( + progress = progress, + modifier = line.height(WavyLineHeight), + color = color, + trackColor = trackColor, + stroke = stroke, + trackStroke = stroke, + amplitude = { if (waving) 1f else 0f }, + waveSpeed = if (moving) WavyProgressIndicatorDefaults.LinearDeterminateWavelength else 0.dp, + stopSize = 0.dp, + ) + } + + else -> { + Unit + } + } +} + +/** Whether [style] draws its line over the bar's content rather than behind it. */ +internal fun MiniBarProgress.isLine(): Boolean = this == MiniBarProgress.LINE || this == MiniBarProgress.WAVY_LINE diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceMusicMini.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceMusicMini.kt index 5bcacc10f..1aee708b4 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceMusicMini.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceMusicMini.kt @@ -1,26 +1,45 @@ package io.github.aedev.flow.ui.screens.settings.appearance.player +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.aedev.flow.R import io.github.aedev.flow.data.local.CoverShape +import io.github.aedev.flow.data.local.MiniBarProgress import io.github.aedev.flow.data.local.MiniCoverMotion import io.github.aedev.flow.data.local.MiniCoverShape +import io.github.aedev.flow.ui.components.musicplayer.mini.MiniBarProgressLine import io.github.aedev.flow.ui.components.musicplayer.mini.coverShapeLabel +import io.github.aedev.flow.ui.components.musicplayer.mini.isLine +import io.github.aedev.flow.ui.components.musicplayer.mini.miniBarProgress import io.github.aedev.flow.ui.components.settings.SettingsListScope +import io.github.aedev.flow.ui.components.settings.SettingsPreviewSheet import io.github.aedev.flow.ui.components.settings.nav +import io.github.aedev.flow.ui.components.settings.switch import io.github.aedev.flow.ui.components.settings.toggleGroup import io.github.aedev.flow.ui.components.shared.FlowToggleOption +import io.github.aedev.flow.ui.components.shared.MediaMiniBarDefaults import io.github.aedev.flow.ui.screens.settings.index.PlayerAppearanceIndex -internal enum class MusicMiniSheet { COVER_SHAPES, COVER_PAUSED } +internal enum class MusicMiniSheet { COVER_SHAPES, COVER_PAUSED, BAR_PROGRESS } /** * What the music mini player group shows and which of its sheets is open. Every field is state, so @@ -35,6 +54,7 @@ internal class MusicMiniSettings { var coverMotionOptions by mutableStateOf(emptyList>()) var coverShapesLabel by mutableStateOf("") var pausedShapeLabel by mutableStateOf("") + var barProgressLabel by mutableStateOf("") var sheet by mutableStateOf(null) } @@ -60,6 +80,8 @@ internal fun rememberMusicMiniSettings(viewModel: PlayerAppearanceViewModel): Mu ) settings.coverShapesLabel = pluralStringResource(R.plurals.player_appearance_cover_shapes_count, coverShapes.size, coverShapes.size) settings.pausedShapeLabel = stringResource(pausedShape?.let { coverShapeLabel(it) } ?: R.string.player_appearance_cover_paused_keep) + val barProgress by viewModel.miniBarProgress.collectAsStateWithLifecycle() + settings.barProgressLabel = stringResource(barProgressLabel(barProgress)) return settings } @@ -86,6 +108,12 @@ internal fun SettingsListScope.musicMiniGroup( onClick = { settings.sheet = MusicMiniSheet.COVER_PAUSED }, ) } + nav( + PlayerAppearanceIndex.miniBarProgress, + value = settings.barProgressLabel, + onClick = { settings.sheet = MusicMiniSheet.BAR_PROGRESS }, + ) + switch(PlayerAppearanceIndex.miniCoverRing, viewModel.miniCoverRing, viewModel::setMiniCoverRing) } @Composable @@ -117,8 +145,58 @@ internal fun MusicMiniSheets( ) } + MusicMiniSheet.BAR_PROGRESS -> { + val selected by viewModel.miniBarProgress.collectAsStateWithLifecycle() + SettingsPreviewSheet( + title = stringResource(R.string.player_appearance_bar_progress_title), + subtitle = stringResource(R.string.player_appearance_bar_progress_subtitle), + options = MiniBarProgress.entries, + selected = selected, + label = { stringResource(barProgressLabel(it)) }, + onSelect = viewModel::setMiniBarProgress, + onDismiss = { settings.sheet = null }, + preview = { BarProgressPreview(it) }, + ) + } + null -> { Unit } } } + +@StringRes +private fun barProgressLabel(style: MiniBarProgress): Int = + when (style) { + MiniBarProgress.NONE -> R.string.player_appearance_bar_progress_none + MiniBarProgress.FILL -> R.string.player_appearance_bar_progress_fill + MiniBarProgress.EDGE -> R.string.player_appearance_bar_progress_edge + MiniBarProgress.LINE -> R.string.player_appearance_bar_progress_line + MiniBarProgress.WAVY_LINE -> R.string.player_appearance_bar_progress_wavy + } + +private const val PREVIEW_PROGRESS = 0.4f + +/** A mini bar's shape at 40 % in [style], still: the line styles show their resting look. */ +@Composable +private fun BarProgressPreview(style: MiniBarProgress) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(MediaMiniBarDefaults.Height) + .clip(RoundedCornerShape(MediaMiniBarDefaults.CornerRadius)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .miniBarProgress(style, { PREVIEW_PROGRESS }, MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onSurface), + ) { + if (style.isLine()) { + MiniBarProgressLine( + style = style, + progress = { PREVIEW_PROGRESS }, + isPlaying = false, + animationsEnabled = false, + modifier = Modifier.align(Alignment.CenterStart).padding(start = 72.dp, end = 120.dp, top = 30.dp), + ) + } + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceViewModel.kt index 56fe94788..8f462a81a 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceViewModel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceViewModel.kt @@ -8,6 +8,7 @@ import io.github.aedev.flow.data.local.DefaultCoverShapes import io.github.aedev.flow.data.local.FullscreenSidePanelWidth import io.github.aedev.flow.data.local.GestureOverlayStyle import io.github.aedev.flow.data.local.IdleProgressBar +import io.github.aedev.flow.data.local.MiniBarProgress import io.github.aedev.flow.data.local.MiniBarSwipeAction import io.github.aedev.flow.data.local.MiniCoverMotion import io.github.aedev.flow.data.local.MiniCoverShape @@ -51,6 +52,8 @@ class PlayerAppearanceViewModel val miniCoverMotion = preferences.miniCoverMotion.asState(MiniCoverMotion.ON_PLAY) val miniCoverShapes = preferences.miniCoverShapes.asState(DefaultCoverShapes) val miniCoverPausedShape = preferences.miniCoverPausedShape.asState(CoverShape.CIRCLE) + val miniBarProgress = preferences.miniBarProgress.asState(MiniBarProgress.NONE) + val miniCoverRing = preferences.miniCoverRing.asState(true) val adaptivePlayerSize = preferences.adaptivePlayerSizeEnabled.asState(true) val playerScrollAway = preferences.playerScrollAwayEnabled.asState(false) val ambientMode = preferences.videoAmbientModeEnabled.asState(false) @@ -119,6 +122,10 @@ class PlayerAppearanceViewModel fun setMiniCoverPausedShape(shape: CoverShape?) = write { preferences.setMiniCoverPausedShape(shape) } + fun setMiniBarProgress(value: MiniBarProgress) = write { preferences.setMiniBarProgress(value) } + + fun setMiniCoverRing(value: Boolean) = write { preferences.setMiniCoverRing(value) } + fun setAdaptivePlayerSize(value: Boolean) = write { preferences.setAdaptivePlayerSizeEnabled(value) } fun setPlayerScrollAway(value: Boolean) = write { preferences.setPlayerScrollAwayEnabled(value) } diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlayerAppearanceIndex.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlayerAppearanceIndex.kt index 8f88fc828..20db13448 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlayerAppearanceIndex.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlayerAppearanceIndex.kt @@ -148,6 +148,20 @@ internal object PlayerAppearanceIndex { section = R.string.settings_section_music_mini_player, revealVia = miniCoverShape.key, ) + val miniBarProgress = + entry( + "mini_bar_progress", + R.string.player_appearance_bar_progress_title, + R.string.player_appearance_bar_progress_subtitle, + R.string.settings_section_music_mini_player, + ) + val miniCoverRing = + entry( + "mini_cover_ring", + R.string.player_appearance_cover_ring_title, + R.string.player_appearance_cover_ring_subtitle, + R.string.settings_section_music_mini_player, + ) val queueSwipeLeft = entry("queue_swipe_left", R.string.queue_swipe_left, R.string.queue_swipe_subtitle, R.string.settings_section_music_player) val queueSwipeRight = @@ -293,6 +307,8 @@ internal object PlayerAppearanceIndex { miniCoverMotion, miniCoverShapes, miniCoverPausedShape, + miniBarProgress, + miniCoverRing, queueSwipeLeft, queueSwipeRight, adaptiveSize, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d56158ac4..cf88fdc7f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -232,6 +232,15 @@ %d shape %d shapes + Bar progress + How the bar shows where the song is + None + Fill + Edge + Line + Wavy line + Ring around the cover + Show progress around the cover too When paused The shape the cover rests in Keep the song\'s shape diff --git a/app/src/test/java/io/github/aedev/flow/data/local/DevicePreferencesSplitTest.kt b/app/src/test/java/io/github/aedev/flow/data/local/DevicePreferencesSplitTest.kt index 7b6e65e21..b1b7bca14 100644 --- a/app/src/test/java/io/github/aedev/flow/data/local/DevicePreferencesSplitTest.kt +++ b/app/src/test/java/io/github/aedev/flow/data/local/DevicePreferencesSplitTest.kt @@ -25,7 +25,7 @@ class DevicePreferencesSplitTest { val device = keyNames("DeviceKeys") assertThat(device).hasSize(124) - assertThat(profile).hasSize(256) + assertThat(profile).hasSize(258) assertThat(device.toSet()).hasSize(device.size) assertThat(profile.toSet()).hasSize(profile.size) assertThat(device.intersect(profile.toSet())).isEmpty()