Redesign music widget
- remove album title - add seek bar
This commit is contained in:
parent
14902ba085
commit
4a06158c6a
@ -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)
|
||||||
}
|
}
|
||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user