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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,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
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<MiniBarProgress> =
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<Boolean> =
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<MusicPlainControlColors> =
context.playerPreferencesDataStore.data
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -120,6 +120,7 @@ private fun CircleCover(
thumbnailUrl: String?,
isPlaying: Boolean,
animationsEnabled: Boolean,
showRing: Boolean,
ringProgress: () -> Float,
ringTrackColor: Color,
ringFillColor: Color,
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,6 +20,8 @@ internal data class MiniPlayerLook(
val coverMotion: MiniCoverMotion = MiniCoverMotion.ON_PLAY,
val coverShapes: List<CoverShape> = 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. */
Expand All @@ -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)
}
}
Loading
Loading