Redesign music widget

- remove album title
- add seek bar
This commit is contained in:
MM20 2023-02-13 13:22:10 +01:00
parent 14902ba085
commit 4a06158c6a
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
2 changed files with 165 additions and 61 deletions

View File

@ -2,24 +2,41 @@ package de.mm20.launcher2.ui.launcher.widgets.music
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Audiotrack import androidx.compose.material.icons.rounded.Audiotrack
import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.MusicNote
import androidx.compose.material.icons.rounded.SkipNext import androidx.compose.material.icons.rounded.SkipNext
import androidx.compose.material.icons.rounded.SkipPrevious import androidx.compose.material.icons.rounded.SkipPrevious
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -29,15 +46,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.music.PlaybackState import de.mm20.launcher2.music.PlaybackState
@ -56,8 +75,9 @@ fun MusicWidget() {
val albumArt by viewModel.albumArt.observeAsState() val albumArt by viewModel.albumArt.observeAsState()
val title by viewModel.title.observeAsState() val title by viewModel.title.observeAsState()
val artist by viewModel.artist.observeAsState() val artist by viewModel.artist.observeAsState()
val album by viewModel.album.observeAsState()
val playbackState by viewModel.playbackState.observeAsState() val playbackState by viewModel.playbackState.observeAsState()
val position by viewModel.position.observeAsState()
val duration by viewModel.duration.observeAsState()
val context = LocalContext.current val context = LocalContext.current
@ -74,7 +94,7 @@ fun MusicWidget() {
} }
) )
} }
if (title == null && artist == null && album == null) { if (title == null && artist == null) {
NoData() NoData()
} else { } else {
Row( Row(
@ -84,71 +104,109 @@ fun MusicWidget() {
modifier = Modifier modifier = Modifier
.padding(top = 16.dp) .padding(top = 16.dp)
.fillMaxHeight() .fillMaxHeight()
.weight(2f), .weight(2f)
verticalArrangement = Arrangement.SpaceBetween
) { ) {
Column( Column(
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp)
) { ) {
Text( Text(
text = title ?: "", text = title ?: "",
modifier = if (playbackState != PlaybackState.Playing) Modifier
else Modifier.basicMarquee(iterations = Int.MAX_VALUE),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = artist ?: "", text = artist ?: "",
modifier = Modifier.padding(vertical = 4.dp), modifier = Modifier
style = MaterialTheme.typography.bodySmall, .padding(top = 4.dp)
maxLines = 1, .then(
overflow = TextOverflow.Ellipsis if (playbackState != PlaybackState.Playing) Modifier
) else Modifier.basicMarquee(iterations = Int.MAX_VALUE)
Text( ),
text = album ?: "",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
Row( Column {
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp, end = 4.dp), val dur = duration
horizontalArrangement = Arrangement.SpaceBetween var pos by remember(position) { mutableStateOf(position) }
) {
IconButton( val interactionSource = remember { MutableInteractionSource() }
onClick = { val isDragged by interactionSource.collectIsDraggedAsState()
viewModel.skipPrevious()
}) { var seekPosition by remember { mutableStateOf<Float?>(null) }
Icon(
imageVector = Icons.Rounded.SkipPrevious, if (pos != null && dur != null && dur > 0) {
null if (playbackState != PlaybackState.Stopped) {
Slider(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.requiredHeightIn(max = 20.dp),
value = (if (isDragged) seekPosition else pos?.toFloat()) ?: 0f ,
valueRange = 0f..dur.toFloat(),
interactionSource = interactionSource,
onValueChange = {
seekPosition = it
},
onValueChangeFinished = {
seekPosition?.let {
viewModel.seekTo(it.toLong())
pos = it.toLong()
}
},
)
} else {
LinearProgressIndicator(
progress = (pos?.toFloat() ?: 0f) / dur.toFloat(),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp),
strokeCap = StrokeCap.Round,
)
}
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
) )
} }
val playPauseIcon = Row(
AnimatedImageVector.animatedVectorResource(R.drawable.anim_ic_play_pause) modifier = Modifier
IconButton(onClick = { viewModel.togglePause() }) { .fillMaxWidth()
Icon( .padding(top = 4.dp, start = 16.dp, end = 16.dp),
painter = rememberAnimatedVectorPainter( verticalAlignment = Alignment.CenterVertically,
playPauseIcon, horizontalArrangement = Arrangement.SpaceBetween,
atEnd = playbackState == PlaybackState.Playing ) {
Text(
text = formatTimestamp(
if (isDragged) seekPosition?.toLong() else pos
), ),
contentDescription = "" style = MaterialTheme.typography.labelSmall,
) )
} Text(
IconButton(onClick = { text = formatTimestamp(duration),
viewModel.skipNext() style = MaterialTheme.typography.labelSmall,
}) {
Icon(
imageVector = Icons.Rounded.SkipNext,
null
) )
} }
} }
} }
Box( Box(
modifier = Modifier modifier = Modifier
.size(144.dp) .padding(top = 16.dp, end = 16.dp)
.size(96.dp)
.clip(MaterialTheme.shapes.small)
.combinedClickable( .combinedClickable(
onClick = { onClick = {
viewModel.openPlayer() viewModel.openPlayer()
@ -160,7 +218,7 @@ fun MusicWidget() {
.conditional( .conditional(
albumArt == null, albumArt == null,
Modifier.background( Modifier.background(
MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.secondaryContainer,
) )
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@ -183,24 +241,16 @@ fun MusicWidget() {
if ( if (
it.componentName.packageName == viewModel.currentPlayerPackage && it.componentName.packageName == viewModel.currentPlayerPackage &&
bounds.right > 0f && bounds.left < windowSize.width && bounds.right > 0f && bounds.left < windowSize.width &&
bounds.bottom > 0f && bounds.top < windowSize.height bounds.bottom > 0f && bounds.top < windowSize.height
) { ) {
return@HandleHomeTransition HomeTransitionParams( return@HandleHomeTransition HomeTransitionParams(
bounds bounds
) { _, _ -> ) { _, _ ->
val shape = MaterialTheme.shapes.medium val shape = MaterialTheme.shapes.small
Image( Image(
bitmap = art.asImageBitmap(), bitmap = art.asImageBitmap(),
modifier = Modifier modifier = Modifier
.size(144.dp) .size(96.dp),
.graphicsLayer {
this.clip = true
this.shape = shape.copy(
topStart = CornerSize(0f),
bottomStart = CornerSize(0f),
)
}
,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
@ -213,13 +263,55 @@ fun MusicWidget() {
Icon( Icon(
imageVector = Icons.Rounded.MusicNote, imageVector = Icons.Rounded.MusicNote,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer, tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(56.dp) modifier = Modifier.size(48.dp)
) )
} }
} }
} }
} }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {
viewModel.skipPrevious()
}) {
Icon(
imageVector = Icons.Rounded.SkipPrevious,
null
)
}
val playPauseIcon =
AnimatedImageVector.animatedVectorResource(R.drawable.anim_ic_play_pause)
FilledTonalIconButton(
modifier = Modifier
.size(40.dp),
onClick = { viewModel.togglePause() },
shape = MaterialTheme.shapes.extraSmall,
) {
Icon(
painter = rememberAnimatedVectorPainter(
playPauseIcon,
atEnd = playbackState == PlaybackState.Playing
),
contentDescription = null
)
}
IconButton(onClick = {
viewModel.skipNext()
}) {
Icon(
imageVector = Icons.Rounded.SkipNext,
null
)
}
}
} }
} }
@ -244,4 +336,11 @@ fun NoData() {
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )
} }
}
private fun formatTimestamp(timestamp: Long?): String {
if (timestamp == null) return "--:--"
val minutes = timestamp / 1000 / 60
val seconds = timestamp / 1000 % 60
return String.format("%02d:%02d", minutes, seconds)
} }

View File

@ -21,9 +21,10 @@ class MusicWidgetVM: ViewModel(), KoinComponent {
val title: LiveData<String?> = musicService.title.asLiveData() val title: LiveData<String?> = musicService.title.asLiveData()
val artist: LiveData<String?> = musicService.artist.asLiveData() val artist: LiveData<String?> = musicService.artist.asLiveData()
val album: LiveData<String?> = musicService.album.asLiveData()
val albumArt: LiveData<Bitmap?> = musicService.albumArt.asLiveData() val albumArt: LiveData<Bitmap?> = musicService.albumArt.asLiveData()
val playbackState: LiveData<PlaybackState> = musicService.playbackState.asLiveData() val playbackState: LiveData<PlaybackState> = musicService.playbackState.asLiveData()
val duration: LiveData<Long?> = musicService.duration.asLiveData()
val position: LiveData<Long?> = musicService.position.asLiveData()
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Notifications).asLiveData() val hasPermission = permissionsManager.hasPermission(PermissionGroup.Notifications).asLiveData()
@ -38,6 +39,10 @@ class MusicWidgetVM: ViewModel(), KoinComponent {
musicService.next() musicService.next()
} }
fun seekTo(position: Long) {
musicService.seekTo(position)
}
fun togglePause() { fun togglePause() {
musicService.togglePause() musicService.togglePause()
} }