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.compose.animation.AnimatedVisibility
import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.interaction.MutableInteractionSource
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.rounded.Audiotrack
import androidx.compose.material.icons.rounded.MusicNote
import androidx.compose.material.icons.rounded.SkipNext
import androidx.compose.material.icons.rounded.SkipPrevious
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -29,15 +46,17 @@ 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.geometry.Rect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.music.PlaybackState
@ -56,8 +75,9 @@ fun MusicWidget() {
val albumArt by viewModel.albumArt.observeAsState()
val title by viewModel.title.observeAsState()
val artist by viewModel.artist.observeAsState()
val album by viewModel.album.observeAsState()
val playbackState by viewModel.playbackState.observeAsState()
val position by viewModel.position.observeAsState()
val duration by viewModel.duration.observeAsState()
val context = LocalContext.current
@ -74,7 +94,7 @@ fun MusicWidget() {
}
)
}
if (title == null && artist == null && album == null) {
if (title == null && artist == null) {
NoData()
} else {
Row(
@ -84,71 +104,109 @@ fun MusicWidget() {
modifier = Modifier
.padding(top = 16.dp)
.fillMaxHeight()
.weight(2f),
verticalArrangement = Arrangement.SpaceBetween
.weight(2f)
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp)
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp)
) {
Text(
text = title ?: "",
modifier = if (playbackState != PlaybackState.Playing) Modifier
else Modifier.basicMarquee(iterations = Int.MAX_VALUE),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = artist ?: "",
modifier = Modifier.padding(vertical = 4.dp),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = album ?: "",
modifier = Modifier
.padding(top = 4.dp)
.then(
if (playbackState != PlaybackState.Playing) Modifier
else Modifier.basicMarquee(iterations = Int.MAX_VALUE)
),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp, end = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(
onClick = {
viewModel.skipPrevious()
}) {
Icon(
imageVector = Icons.Rounded.SkipPrevious,
null
Column {
val dur = duration
var pos by remember(position) { mutableStateOf(position) }
val interactionSource = remember { MutableInteractionSource() }
val isDragged by interactionSource.collectIsDraggedAsState()
var seekPosition by remember { mutableStateOf<Float?>(null) }
if (pos != null && dur != null && dur > 0) {
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 =
AnimatedImageVector.animatedVectorResource(R.drawable.anim_ic_play_pause)
IconButton(onClick = { viewModel.togglePause() }) {
Icon(
painter = rememberAnimatedVectorPainter(
playPauseIcon,
atEnd = playbackState == PlaybackState.Playing
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp, start = 16.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = formatTimestamp(
if (isDragged) seekPosition?.toLong() else pos
),
contentDescription = ""
style = MaterialTheme.typography.labelSmall,
)
}
IconButton(onClick = {
viewModel.skipNext()
}) {
Icon(
imageVector = Icons.Rounded.SkipNext,
null
Text(
text = formatTimestamp(duration),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
Box(
modifier = Modifier
.size(144.dp)
.padding(top = 16.dp, end = 16.dp)
.size(96.dp)
.clip(MaterialTheme.shapes.small)
.combinedClickable(
onClick = {
viewModel.openPlayer()
@ -160,7 +218,7 @@ fun MusicWidget() {
.conditional(
albumArt == null,
Modifier.background(
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.secondaryContainer,
)
),
contentAlignment = Alignment.Center
@ -183,24 +241,16 @@ fun MusicWidget() {
if (
it.componentName.packageName == viewModel.currentPlayerPackage &&
bounds.right > 0f && bounds.left < windowSize.width &&
bounds.bottom > 0f && bounds.top < windowSize.height
bounds.bottom > 0f && bounds.top < windowSize.height
) {
return@HandleHomeTransition HomeTransitionParams(
bounds
) { _, _ ->
val shape = MaterialTheme.shapes.medium
val shape = MaterialTheme.shapes.small
Image(
bitmap = art.asImageBitmap(),
modifier = Modifier
.size(144.dp)
.graphicsLayer {
this.clip = true
this.shape = shape.copy(
topStart = CornerSize(0f),
bottomStart = CornerSize(0f),
)
}
,
.size(96.dp),
contentDescription = null,
contentScale = ContentScale.Crop
)
@ -213,13 +263,55 @@ fun MusicWidget() {
Icon(
imageVector = Icons.Rounded.MusicNote,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp)
tint = MaterialTheme.colorScheme.onSecondaryContainer,
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
)
}
}
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 artist: LiveData<String?> = musicService.artist.asLiveData()
val album: LiveData<String?> = musicService.album.asLiveData()
val albumArt: LiveData<Bitmap?> = musicService.albumArt.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()
@ -38,6 +39,10 @@ class MusicWidgetVM: ViewModel(), KoinComponent {
musicService.next()
}
fun seekTo(position: Long) {
musicService.seekTo(position)
}
fun togglePause() {
musicService.togglePause()
}