Initial commit
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
package it.vfsfitvnm.vimusic.ui.components
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.DraggableState
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun BottomSheet(
|
||||
lowerBound: Dp,
|
||||
upperBound: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
peekHeight: Dp = 0.dp,
|
||||
elevation: Dp = 8.dp,
|
||||
shape: Shape = RectangleShape,
|
||||
handleOutsideInteractionsWhenExpanded: Boolean = false,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
collapsedContent: @Composable BoxScope.() -> Unit,
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) {
|
||||
BottomSheet(
|
||||
state = rememberBottomSheetState(lowerBound, upperBound),
|
||||
modifier = modifier,
|
||||
peekHeight = peekHeight,
|
||||
elevation = elevation,
|
||||
shape = shape,
|
||||
handleOutsideInteractionsWhenExpanded = handleOutsideInteractionsWhenExpanded,
|
||||
interactionSource = interactionSource,
|
||||
collapsedContent = collapsedContent,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomSheet(
|
||||
state: BottomSheetState,
|
||||
modifier: Modifier = Modifier,
|
||||
peekHeight: Dp = 0.dp,
|
||||
elevation: Dp = 8.dp,
|
||||
shape: Shape = RectangleShape,
|
||||
handleOutsideInteractionsWhenExpanded: Boolean = false,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
collapsedContent: @Composable BoxScope.() -> Unit,
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) {
|
||||
var lastOffset by remember {
|
||||
mutableStateOf(state.value)
|
||||
}
|
||||
|
||||
BackHandler(enabled = !state.isCollapsed, onBack = state.collapse)
|
||||
|
||||
Box {
|
||||
if (handleOutsideInteractionsWhenExpanded && !state.isCollapsed) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures {
|
||||
state.collapse()
|
||||
}
|
||||
}
|
||||
.draggable(
|
||||
state = state,
|
||||
onDragStarted = {
|
||||
lastOffset = state.value
|
||||
},
|
||||
onDragStopped = { velocity ->
|
||||
if (velocity.absoluteValue > 300 && lastOffset != state.value) {
|
||||
if (lastOffset > state.value) {
|
||||
state.collapse()
|
||||
} else {
|
||||
state.expand()
|
||||
}
|
||||
} else {
|
||||
if (state.upperBound - state.value > state.value - state.lowerBound) {
|
||||
state.collapse()
|
||||
} else {
|
||||
state.expand()
|
||||
}
|
||||
}
|
||||
},
|
||||
orientation = Orientation.Vertical
|
||||
)
|
||||
.drawBehind {
|
||||
drawRect(color = Color.Black.copy(alpha = 0.5f * state.progress))
|
||||
}
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.offset {
|
||||
val y = (state.upperBound - state.value + peekHeight)
|
||||
.roundToPx()
|
||||
.coerceAtLeast(0)
|
||||
IntOffset(x = 0, y = y)
|
||||
}
|
||||
.shadow(elevation = elevation, shape = shape)
|
||||
.clip(shape)
|
||||
.draggable(
|
||||
state = state,
|
||||
interactionSource = interactionSource,
|
||||
onDragStarted = {
|
||||
lastOffset = state.value
|
||||
},
|
||||
onDragStopped = { velocity ->
|
||||
if (velocity.absoluteValue > 300 && lastOffset != state.value) {
|
||||
if (lastOffset > state.value) {
|
||||
state.collapse()
|
||||
} else {
|
||||
state.expand()
|
||||
}
|
||||
} else {
|
||||
if (state.upperBound - state.value > state.value - state.lowerBound) {
|
||||
state.collapse()
|
||||
} else {
|
||||
state.expand()
|
||||
}
|
||||
}
|
||||
},
|
||||
orientation = Orientation.Vertical
|
||||
)
|
||||
.clickable(
|
||||
enabled = !state.isRunning && state.isCollapsed,
|
||||
indication = null,
|
||||
interactionSource = interactionSource
|
||||
?: remember { MutableInteractionSource() },
|
||||
onClick = state.expand
|
||||
)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
if (!state.isCollapsed) {
|
||||
content()
|
||||
}
|
||||
|
||||
collapsedContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Stable
|
||||
class BottomSheetState(
|
||||
draggableState: DraggableState,
|
||||
valueState: State<Dp>,
|
||||
isRunningState: State<Boolean>,
|
||||
isCollapsedState: State<Boolean>,
|
||||
isExpandedState: State<Boolean>,
|
||||
progressState: State<Float>,
|
||||
val lowerBound: Dp,
|
||||
val upperBound: Dp,
|
||||
val collapse: () -> Unit,
|
||||
val expand: () -> Unit,
|
||||
) : DraggableState by draggableState {
|
||||
val value by valueState
|
||||
|
||||
val isRunning by isRunningState
|
||||
|
||||
val isCollapsed by isCollapsedState
|
||||
|
||||
val isExpanded by isExpandedState
|
||||
|
||||
val progress by progressState
|
||||
|
||||
fun nestedScrollConnection(initialIsTopReached: Boolean = true): NestedScrollConnection {
|
||||
return object : NestedScrollConnection {
|
||||
var isTopReached = initialIsTopReached
|
||||
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
if (isExpanded && available.y < 0) {
|
||||
isTopReached = false
|
||||
}
|
||||
|
||||
if (isTopReached) {
|
||||
dispatchRawDelta(available.y)
|
||||
return available
|
||||
}
|
||||
|
||||
return Offset.Zero
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
if (!isTopReached) {
|
||||
isTopReached = consumed.y == 0f && available.y > 0
|
||||
}
|
||||
|
||||
return Offset.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
if (isTopReached) {
|
||||
coroutineScope {
|
||||
if (available.y.absoluteValue > 1000) {
|
||||
collapse()
|
||||
} else {
|
||||
if (upperBound - value > value - lowerBound) {
|
||||
collapse()
|
||||
} else {
|
||||
expand()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return available
|
||||
}
|
||||
|
||||
return Velocity.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
isTopReached = false
|
||||
return super.onPostFling(consumed, available)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberBottomSheetState(lowerBound: Dp, upperBound: Dp): BottomSheetState {
|
||||
val density = LocalDensity.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var wasExpanded by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val animatable = remember(lowerBound, upperBound) {
|
||||
Animatable(if (wasExpanded) upperBound else lowerBound, Dp.VectorConverter).also {
|
||||
it.updateBounds(lowerBound, upperBound)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(animatable.value == upperBound) {
|
||||
wasExpanded = animatable.value == upperBound
|
||||
}
|
||||
|
||||
return remember(animatable, coroutineScope) {
|
||||
BottomSheetState(
|
||||
draggableState = DraggableState { delta ->
|
||||
coroutineScope.launch {
|
||||
animatable.snapTo(animatable.value - density.run { delta.toDp() })
|
||||
}
|
||||
},
|
||||
valueState = animatable.asState(),
|
||||
lowerBound = lowerBound,
|
||||
upperBound = upperBound,
|
||||
isRunningState = derivedStateOf {
|
||||
animatable.isRunning
|
||||
},
|
||||
isCollapsedState = derivedStateOf {
|
||||
animatable.value == lowerBound
|
||||
},
|
||||
isExpandedState = derivedStateOf {
|
||||
animatable.value == upperBound
|
||||
},
|
||||
progressState = derivedStateOf {
|
||||
1f - (upperBound - animatable.value) / (upperBound - lowerBound)
|
||||
},
|
||||
collapse = {
|
||||
coroutineScope.launch {
|
||||
animatable.animateTo(animatable.lowerBound!!)
|
||||
}
|
||||
},
|
||||
expand = {
|
||||
coroutineScope.launch {
|
||||
animatable.animateTo(animatable.upperBound!!)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package it.vfsfitvnm.vimusic.ui.components
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
|
||||
@Composable
|
||||
fun ChunkyButton(
|
||||
onClick: () -> Unit,
|
||||
backgroundColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
text: String? = null,
|
||||
secondaryText: String? = null,
|
||||
textStyle: TextStyle = TextStyle.Default,
|
||||
secondaryTextStyle: TextStyle = TextStyle.Default,
|
||||
rippleColor: Color = Color.Unspecified,
|
||||
@DrawableRes icon: Int? = null,
|
||||
shape: Shape = RoundedCornerShape(16.dp),
|
||||
colorFilter: ColorFilter = ColorFilter.tint(rippleColor),
|
||||
onMore: (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(backgroundColor)
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true, color = rippleColor),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = onClick
|
||||
)
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
icon?.let { icon ->
|
||||
Image(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
colorFilter = colorFilter,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
text?.let { text ->
|
||||
Column {
|
||||
BasicText(
|
||||
text = text,
|
||||
style = textStyle
|
||||
)
|
||||
|
||||
secondaryText?.let { secondaryText ->
|
||||
BasicText(
|
||||
text = secondaryText,
|
||||
style = secondaryTextStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMore?.let { onMore ->
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.background(rippleColor.copy(alpha = 0.6f))
|
||||
.width(1.dp)
|
||||
.height(24.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
// TODO: this is themed...
|
||||
painter = painterResource(it.vfsfitvnm.vimusic.R.drawable.ellipsis_vertical),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(rippleColor.copy(alpha = 0.6f)),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onMore)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package it.vfsfitvnm.vimusic.ui.components
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun <T>ChipGroup(
|
||||
items: List<ChipItem<T>>,
|
||||
value: T,
|
||||
selectedBackgroundColor: Color,
|
||||
unselectedBackgroundColor: Color,
|
||||
selectedTextStyle: TextStyle,
|
||||
unselectedTextStyle: TextStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
shape: Shape = RoundedCornerShape(16.dp),
|
||||
onValueChanged: (T) -> Unit
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.then(modifier)
|
||||
) {
|
||||
items.forEach { chipItem ->
|
||||
ChunkyButton(
|
||||
text = chipItem.text,
|
||||
textStyle = if (chipItem.value == value) selectedTextStyle else unselectedTextStyle,
|
||||
backgroundColor = if (chipItem.value == value) selectedBackgroundColor else unselectedBackgroundColor,
|
||||
shape = shape,
|
||||
onClick = {
|
||||
onValueChanged(chipItem.value)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ChipItem<T>(
|
||||
val text: String,
|
||||
val value: T
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
package it.vfsfitvnm.vimusic.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ExpandableText(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
showMoreTextStyle: TextStyle,
|
||||
minimizedMaxLines: Int,
|
||||
backgroundColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isExpanded by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var hasVisualOverflow by remember {
|
||||
mutableStateOf(true)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
) {
|
||||
Box {
|
||||
BasicText(
|
||||
text = text,
|
||||
maxLines = if (isExpanded) Int.MAX_VALUE else minimizedMaxLines,
|
||||
onTextLayout = {
|
||||
hasVisualOverflow = it.hasVisualOverflow
|
||||
},
|
||||
style = style
|
||||
)
|
||||
|
||||
if (hasVisualOverflow) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.height(14.dp)
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
backgroundColor.copy(alpha = 0.5f),
|
||||
backgroundColor
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BasicText(
|
||||
text = if (isExpanded) "Less" else "More",
|
||||
style = showMoreTextStyle,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = { isExpanded = !isExpanded }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package it.vfsfitvnm.vimusic.ui.components
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
|
||||
val LocalMenuState = compositionLocalOf<MenuState> { TODO() }
|
||||
|
||||
class MenuState(isDisplayedState: MutableState<Boolean>) {
|
||||
var isDisplayed by isDisplayedState
|
||||
private set
|
||||
|
||||
var content: @Composable () -> Unit = {}
|
||||
|
||||
fun display(content: @Composable () -> Unit) {
|
||||
this.content = content
|
||||
isDisplayed = true
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
isDisplayed = false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberMenuState(): MenuState {
|
||||
val isDisplayedState = remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
return remember {
|
||||
MenuState(
|
||||
isDisplayedState = isDisplayedState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomSheetMenu(
|
||||
state: MenuState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = state.isDisplayed,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
BackHandler(onBack = state::hide)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures {
|
||||
state.hide()
|
||||
}
|
||||
}
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.isDisplayed,
|
||||
enter = slideInVertically { it },
|
||||
exit = slideOutVertically { it },
|
||||
modifier = modifier
|
||||
) {
|
||||
state.content()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package it.vfsfitvnm.vimusic.ui.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun MusicBars(
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
barWidth: Dp = 4.dp,
|
||||
shape: Shape = CircleShape
|
||||
) {
|
||||
val animatablesWithSteps = remember {
|
||||
listOf(
|
||||
Animatable(0f) to listOf(0.2f, 0.8f, 0.1f, 0.1f, 0.3f, 0.1f, 0.2f, 0.8f, 0.7f, 0.2f, 0.4f, 0.9f, 0.7f, 0.6f, 0.1f, 0.3f, 0.1f, 0.4f, 0.1f, 0.8f, 0.7f, 0.9f, 0.5f, 0.6f, 0.3f, 0.1f),
|
||||
Animatable(0f) to listOf(0.2f, 0.5f, 1.0f, 0.5f, 0.3f, 0.1f, 0.2f, 0.3f, 0.5f, 0.1f, 0.6f, 0.5f, 0.3f, 0.7f, 0.8f, 0.9f, 0.3f, 0.1f, 0.5f, 0.3f, 0.6f, 1.0f, 0.6f, 0.7f, 0.4f, 0.1f),
|
||||
Animatable(0f) to listOf(0.6f, 0.5f, 1.0f, 0.6f, 0.5f, 1.0f, 0.6f, 0.5f, 1.0f, 0.5f, 0.6f, 0.7f, 0.2f, 0.3f, 0.1f, 0.5f, 0.4f, 0.6f, 0.7f, 0.1f, 0.4f, 0.3f, 0.1f, 0.4f, 0.3f, 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
animatablesWithSteps.forEach { (animatable, steps) ->
|
||||
launch {
|
||||
while (true) {
|
||||
steps.forEach { step ->
|
||||
animatable.animateTo(step)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
modifier = modifier
|
||||
) {
|
||||
animatablesWithSteps.forEach { (animatable) ->
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.background(color = color, shape = shape)
|
||||
.fillMaxHeight(animatable.value)
|
||||
.width(barWidth)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package it.vfsfitvnm.vimusic.ui.components
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.utils.italic
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
|
||||
@Composable
|
||||
inline fun <T> OutcomeItem(
|
||||
outcome: Outcome<T>,
|
||||
noinline onInitialize: (() -> Unit)? = null,
|
||||
noinline onRetry: (() -> Unit)? = onInitialize,
|
||||
onUninitialized: @Composable () -> Unit = {
|
||||
onInitialize?.let {
|
||||
SideEffect(it)
|
||||
}
|
||||
},
|
||||
onLoading: @Composable () -> Unit = {},
|
||||
onError: @Composable (Outcome.Error) -> Unit = {
|
||||
Error(
|
||||
error = it,
|
||||
onRetry = onRetry,
|
||||
)
|
||||
},
|
||||
onSuccess: @Composable (T) -> Unit
|
||||
) {
|
||||
when (outcome) {
|
||||
is Outcome.Initial -> onUninitialized()
|
||||
is Outcome.Loading -> onLoading()
|
||||
is Outcome.Error -> onError(outcome)
|
||||
is Outcome.Recovered -> onError(outcome.error)
|
||||
is Outcome.Success -> onSuccess(outcome.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Error(
|
||||
error: Outcome.Error,
|
||||
modifier: Modifier = Modifier,
|
||||
onRetry: (() -> Unit)? = null
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(
|
||||
space = 8.dp,
|
||||
alignment = Alignment.CenterVertically
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.alert_circle),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(Color(0xFFFC5F5F)),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(48.dp)
|
||||
)
|
||||
BasicText(
|
||||
text = when (error) {
|
||||
is Outcome.Error.Network -> "Couldn't reach the Internet"
|
||||
is Outcome.Error.Unhandled -> (error.throwable.message ?: error.throwable.toString())
|
||||
},
|
||||
style = LocalTypography.current.xxs.medium.secondary,
|
||||
)
|
||||
|
||||
onRetry?.let { retry ->
|
||||
BasicText(
|
||||
text = "Retry",
|
||||
style = LocalTypography.current.xxs.medium,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = retry)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Message(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
@DrawableRes icon: Int = R.drawable.alert_circle
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(
|
||||
space = 8.dp,
|
||||
alignment = Alignment.CenterVertically
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(LocalColorPalette.current.darkGray),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(36.dp)
|
||||
)
|
||||
BasicText(
|
||||
text = text,
|
||||
style = LocalTypography.current.xs.medium.secondary.italic,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package it.vfsfitvnm.vimusic.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
inline fun TopAppBar(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = modifier
|
||||
.fillMaxWidth(),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package it.vfsfitvnm.vimusic.ui.components.themed
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import it.vfsfitvnm.vimusic.ui.components.ChunkyButton
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.utils.center
|
||||
import it.vfsfitvnm.vimusic.utils.color
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun TextFieldDialog(
|
||||
hintText: String,
|
||||
onDismiss: () -> Unit,
|
||||
onDone: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
cancelText: String = "Cancel",
|
||||
doneText: String = "Done",
|
||||
initialTextInput: String = "",
|
||||
onCancel: () -> Unit = onDismiss,
|
||||
isTextInputValid: (String) -> Boolean = { it.isNotEmpty() }
|
||||
) {
|
||||
val focusRequester = remember {
|
||||
FocusRequester()
|
||||
}
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
||||
var textFieldValue by rememberSaveable(initialTextInput, stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(
|
||||
TextFieldValue(
|
||||
text = initialTextInput,
|
||||
selection = TextRange(initialTextInput.length)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
DefaultDialog(
|
||||
onDismiss = onDismiss,
|
||||
modifier = modifier
|
||||
) {
|
||||
BasicTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = {
|
||||
textFieldValue = it
|
||||
},
|
||||
textStyle = typography.xs.semiBold.center,
|
||||
singleLine = true,
|
||||
maxLines = 1,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
if (isTextInputValid(textFieldValue.text)) {
|
||||
onDismiss()
|
||||
onDone(textFieldValue.text)
|
||||
}
|
||||
}
|
||||
),
|
||||
cursorBrush = SolidColor(colorPalette.text),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = textFieldValue.text.isEmpty(),
|
||||
enter = fadeIn(tween(100)),
|
||||
exit = fadeOut(tween(100)),
|
||||
) {
|
||||
BasicText(
|
||||
text = hintText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = typography.xs.semiBold.secondary,
|
||||
)
|
||||
}
|
||||
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(all = 16.dp)
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
ChunkyButton(
|
||||
backgroundColor = colorPalette.lightBackground,
|
||||
text = cancelText,
|
||||
textStyle = typography.xs.semiBold,
|
||||
shape = RoundedCornerShape(36.dp),
|
||||
onClick = onCancel
|
||||
)
|
||||
|
||||
ChunkyButton(
|
||||
backgroundColor = colorPalette.primaryContainer,
|
||||
text = doneText,
|
||||
textStyle = typography.xs.semiBold.color(colorPalette.onPrimaryContainer),
|
||||
shape = RoundedCornerShape(36.dp),
|
||||
onClick = {
|
||||
if (isTextInputValid(textFieldValue.text)) {
|
||||
onDismiss()
|
||||
onDone(textFieldValue.text)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmationDialog(
|
||||
text: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
cancelText: String = "Cancel",
|
||||
confirmText: String = "Confirm",
|
||||
onCancel: () -> Unit = onDismiss
|
||||
) {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
||||
DefaultDialog(
|
||||
onDismiss = onDismiss,
|
||||
modifier = modifier
|
||||
) {
|
||||
BasicText(
|
||||
text = text,
|
||||
style = typography.xs.semiBold.center,
|
||||
modifier = Modifier
|
||||
.padding(all = 16.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
ChunkyButton(
|
||||
backgroundColor = colorPalette.lightBackground,
|
||||
text = cancelText,
|
||||
textStyle = typography.xs.semiBold,
|
||||
shape = RoundedCornerShape(36.dp),
|
||||
onClick = onCancel
|
||||
)
|
||||
|
||||
ChunkyButton(
|
||||
backgroundColor = colorPalette.primaryContainer,
|
||||
text = confirmText,
|
||||
textStyle = typography.xs.semiBold.color(colorPalette.onPrimaryContainer),
|
||||
shape = RoundedCornerShape(36.dp),
|
||||
onClick = {
|
||||
onConfirm()
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private inline fun DefaultDialog(
|
||||
noinline onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
crossinline content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
.padding(all = 48.dp)
|
||||
.background(
|
||||
color = LocalColorPalette.current.lightBackground,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package it.vfsfitvnm.vimusic.ui.components.themed
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.with
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.route.empty
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.internal
|
||||
import it.vfsfitvnm.vimusic.models.Playlist
|
||||
import it.vfsfitvnm.vimusic.models.SongInPlaylist
|
||||
import it.vfsfitvnm.vimusic.models.SongWithInfo
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.screens.rememberAlbumRoute
|
||||
import it.vfsfitvnm.vimusic.ui.screens.rememberArtistRoute
|
||||
import it.vfsfitvnm.vimusic.ui.screens.rememberCreatePlaylistRoute
|
||||
import it.vfsfitvnm.vimusic.utils.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun InFavoritesMediaItemMenu(
|
||||
song: SongWithInfo,
|
||||
modifier: Modifier = Modifier,
|
||||
// https://issuetracker.google.com/issues/226410236
|
||||
onDismiss: () -> Unit = LocalMenuState.current.let { it::hide }
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
NonQueuedMediaItemMenu(
|
||||
mediaItem = song.asMediaItem,
|
||||
onDismiss = onDismiss,
|
||||
onRemoveFromFavorites = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.update(song.song.toggleLike())
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun InHistoryMediaItemMenu(
|
||||
song: SongWithInfo,
|
||||
modifier: Modifier = Modifier,
|
||||
// https://issuetracker.google.com/issues/226410236
|
||||
onDismiss: () -> Unit = LocalMenuState.current.let { it::hide }
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var isDeletingFromDatabase by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (isDeletingFromDatabase) {
|
||||
ConfirmationDialog(
|
||||
text = "Do you really want to permanently delete this song? It will removed from any playlist as well.\nThis action is irreversible.",
|
||||
onDismiss = {
|
||||
isDeletingFromDatabase = false
|
||||
},
|
||||
onConfirm = {
|
||||
onDismiss()
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.delete(song.song)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
NonQueuedMediaItemMenu(
|
||||
mediaItem = song.asMediaItem,
|
||||
onDismiss = onDismiss,
|
||||
onDeleteFromDatabase = {
|
||||
isDeletingFromDatabase = true
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun InPlaylistMediaItemMenu(
|
||||
playlistId: Long,
|
||||
positionInPlaylist: Int,
|
||||
song: SongWithInfo,
|
||||
modifier: Modifier = Modifier,
|
||||
// https://issuetracker.google.com/issues/226410236
|
||||
onDismiss: () -> Unit = LocalMenuState.current.let { it::hide }
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
NonQueuedMediaItemMenu(
|
||||
mediaItem = song.asMediaItem,
|
||||
onDismiss = onDismiss,
|
||||
onRemoveFromPlaylist = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.internal.runInTransaction {
|
||||
Database.delete(
|
||||
SongInPlaylist(
|
||||
songId = song.song.id,
|
||||
playlistId = playlistId,
|
||||
position = positionInPlaylist
|
||||
)
|
||||
)
|
||||
Database.decrementSongPositions(
|
||||
playlistId = playlistId,
|
||||
fromPosition = positionInPlaylist + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun NonQueuedMediaItemMenu(
|
||||
mediaItem: MediaItem,
|
||||
modifier: Modifier = Modifier,
|
||||
// https://issuetracker.google.com/issues/226410236
|
||||
onDismiss: () -> Unit = LocalMenuState.current.let { it::hide },
|
||||
onRemoveFromPlaylist: (() -> Unit)? = null,
|
||||
onDeleteFromDatabase: (() -> Unit)? = null,
|
||||
onRemoveFromFavorites: (() -> Unit)? = null,
|
||||
) {
|
||||
val player = LocalYoutubePlayer.current
|
||||
|
||||
BaseMediaItemMenu(
|
||||
mediaItem = mediaItem,
|
||||
onDismiss = onDismiss,
|
||||
onStartRadio = {
|
||||
val playlistId = mediaItem.mediaMetadata.extras?.getString("playlistId")
|
||||
YoutubePlayer.Radio.setup(playlistId = playlistId)
|
||||
player?.mediaController?.forcePlay(mediaItem)
|
||||
},
|
||||
onPlayNext = if (player?.playbackState == Player.STATE_READY) ({
|
||||
player.mediaController.addNext(mediaItem)
|
||||
}) else null,
|
||||
onEnqueue = if (player?.playbackState == Player.STATE_READY) ({
|
||||
player.mediaController.enqueue(mediaItem)
|
||||
}) else null,
|
||||
onRemoveFromPlaylist = onRemoveFromPlaylist,
|
||||
onDeleteFromDatabase = onDeleteFromDatabase,
|
||||
onRemoveFromFavorites = onRemoveFromFavorites,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun QueuedMediaItemMenu(
|
||||
mediaItem: MediaItem,
|
||||
indexInQueue: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
// https://issuetracker.google.com/issues/226410236
|
||||
onDismiss: () -> Unit = LocalMenuState.current.let { it::hide },
|
||||
onGlobalRouteEmitted: (() -> Unit)? = null
|
||||
) {
|
||||
val player = LocalYoutubePlayer.current
|
||||
|
||||
BaseMediaItemMenu(
|
||||
mediaItem = mediaItem,
|
||||
onDismiss = onDismiss,
|
||||
onRemoveFromQueue = if (player?.mediaItemIndex != indexInQueue) ({
|
||||
player?.mediaController?.removeMediaItem(indexInQueue)
|
||||
}) else null,
|
||||
onGlobalRouteEmitted = onGlobalRouteEmitted,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun BaseMediaItemMenu(
|
||||
mediaItem: MediaItem,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onStartRadio: (() -> Unit)? = null,
|
||||
onPlayNext: (() -> Unit)? = null,
|
||||
onEnqueue: (() -> Unit)? = null,
|
||||
onRemoveFromQueue: (() -> Unit)? = null,
|
||||
onRemoveFromPlaylist: (() -> Unit)? = null,
|
||||
onDeleteFromDatabase: (() -> Unit)? = null,
|
||||
onRemoveFromFavorites: (() -> Unit)? = null,
|
||||
onGlobalRouteEmitted: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
MediaItemMenu(
|
||||
mediaItem = mediaItem,
|
||||
onDismiss = onDismiss,
|
||||
onStartRadio = onStartRadio,
|
||||
onPlayNext = onPlayNext,
|
||||
onEnqueue = onEnqueue,
|
||||
onAddToPlaylist = { playlist, position ->
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val playlistId = Database.playlist(playlist.id)?.id ?: Database.insert(playlist)
|
||||
|
||||
if (Database.song(mediaItem.mediaId) == null) {
|
||||
Database.insert(mediaItem)
|
||||
}
|
||||
|
||||
Database.insert(
|
||||
SongInPlaylist(
|
||||
songId = mediaItem.mediaId,
|
||||
playlistId = playlistId,
|
||||
position = position
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
onDeleteFromDatabase = onDeleteFromDatabase,
|
||||
onRemoveFromFavorites = onRemoveFromFavorites,
|
||||
onRemoveFromPlaylist = onRemoveFromPlaylist,
|
||||
onRemoveFromQueue = onRemoveFromQueue,
|
||||
onGoToAlbum = albumRoute::global,
|
||||
onGoToArtist = artistRoute::global,
|
||||
onShare = {
|
||||
context.shareAsYouTubeSong(mediaItem)
|
||||
},
|
||||
onGlobalRouteEmitted = onGlobalRouteEmitted,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun MediaItemMenu(
|
||||
mediaItem: MediaItem,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onStartRadio: (() -> Unit)? = null,
|
||||
onPlayNext: (() -> Unit)? = null,
|
||||
onEnqueue: (() -> Unit)? = null,
|
||||
onDeleteFromDatabase: (() -> Unit)? = null,
|
||||
onRemoveFromQueue: (() -> Unit)? = null,
|
||||
onRemoveFromFavorites: (() -> Unit)? = null,
|
||||
onRemoveFromPlaylist: (() -> Unit)? = null,
|
||||
onAddToPlaylist: ((Playlist, Int) -> Unit)? = null,
|
||||
onGoToAlbum: ((String) -> Unit)? = null,
|
||||
onGoToArtist: ((String) -> Unit)? = null,
|
||||
onShare: (() -> Unit)? = null,
|
||||
onGlobalRouteEmitted: (() -> Unit)? = null,
|
||||
) {
|
||||
val playlistPreviews by remember {
|
||||
Database.playlistPreviews()
|
||||
}.collectAsState(initial = emptyList(), context = Dispatchers.IO)
|
||||
|
||||
val viewPlaylistsRoute = rememberCreatePlaylistRoute()
|
||||
|
||||
Menu(
|
||||
modifier = modifier
|
||||
) {
|
||||
RouteHandler(
|
||||
transitionSpec = {
|
||||
when (targetState.route) {
|
||||
viewPlaylistsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Left) with
|
||||
slideOutOfContainer(AnimatedContentScope.SlideDirection.Left)
|
||||
else -> when (initialState.route) {
|
||||
viewPlaylistsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Right) with
|
||||
slideOutOfContainer(AnimatedContentScope.SlideDirection.Right)
|
||||
else -> empty
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
viewPlaylistsRoute {
|
||||
var isCreatingNewPlaylist by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (isCreatingNewPlaylist && onAddToPlaylist != null) {
|
||||
TextFieldDialog(
|
||||
hintText = "Enter the playlist name",
|
||||
onDismiss = {
|
||||
isCreatingNewPlaylist = false
|
||||
},
|
||||
onDone = { text ->
|
||||
onDismiss()
|
||||
onAddToPlaylist(Playlist(name = text), 0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
MenuBackButton(onClick = pop)
|
||||
|
||||
if (onAddToPlaylist != null) {
|
||||
MenuIconButton(
|
||||
icon = R.drawable.add,
|
||||
onClick = {
|
||||
isCreatingNewPlaylist = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onAddToPlaylist?.let { onAddToPlaylist ->
|
||||
playlistPreviews.forEach { playlistPreview ->
|
||||
MenuEntry(
|
||||
icon = R.drawable.list,
|
||||
text = playlistPreview.playlist.name,
|
||||
secondaryText = "${playlistPreview.songCount} songs",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onAddToPlaylist(
|
||||
playlistPreview.playlist,
|
||||
playlistPreview.songCount
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
host {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { }
|
||||
}
|
||||
) {
|
||||
MenuCloseButton(onClick = onDismiss)
|
||||
|
||||
onStartRadio?.let { onStartRadio ->
|
||||
MenuEntry(
|
||||
icon = R.drawable.radio,
|
||||
text = "Start radio",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onStartRadio()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onPlayNext?.let { onPlayNext ->
|
||||
MenuEntry(
|
||||
icon = R.drawable.play,
|
||||
text = "Play next",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onPlayNext()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onEnqueue?.let { onEnqueue ->
|
||||
MenuEntry(
|
||||
icon = R.drawable.time,
|
||||
text = "Enqueue",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onEnqueue()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onRemoveFromQueue?.let { onRemoveFromQueue ->
|
||||
MenuEntry(
|
||||
icon = R.drawable.trash,
|
||||
text = "Remove",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onRemoveFromQueue()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onRemoveFromFavorites?.let { onRemoveFromFavorites ->
|
||||
MenuEntry(
|
||||
icon = R.drawable.heart_dislike,
|
||||
text = "Dislike",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onRemoveFromFavorites()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onRemoveFromPlaylist?.let { onRemoveFromPlaylist ->
|
||||
MenuEntry(
|
||||
icon = R.drawable.trash,
|
||||
text = "Remove",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onRemoveFromPlaylist()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (onAddToPlaylist != null) {
|
||||
MenuEntry(
|
||||
icon = R.drawable.list,
|
||||
text = "Add to playlist",
|
||||
onClick = {
|
||||
viewPlaylistsRoute()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onGoToAlbum?.let { onGoToAlbum ->
|
||||
mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId ->
|
||||
MenuEntry(
|
||||
icon = R.drawable.disc,
|
||||
text = "Go to album",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onGlobalRouteEmitted?.invoke()
|
||||
onGoToAlbum(albumId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onGoToArtist?.let { onGoToArtist ->
|
||||
mediaItem.mediaMetadata.extras?.getStringArrayList("artistNames")
|
||||
?.let { artistNames ->
|
||||
mediaItem.mediaMetadata.extras?.getStringArrayList("artistIds")
|
||||
?.let { artistIds ->
|
||||
artistNames.zip(artistIds)
|
||||
.forEach { (authorName, authorId) ->
|
||||
if (authorId != null) {
|
||||
MenuEntry(
|
||||
icon = R.drawable.person,
|
||||
text = "More of $authorName",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onGlobalRouteEmitted?.invoke()
|
||||
onGoToArtist(authorId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onShare?.let { onShare ->
|
||||
MenuEntry(
|
||||
icon = R.drawable.share_social,
|
||||
text = "Share",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onShare()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onDeleteFromDatabase?.let { onDeleteFromDatabase ->
|
||||
MenuEntry(
|
||||
icon = R.drawable.trash,
|
||||
text = "Delete",
|
||||
onClick = {
|
||||
onDeleteFromDatabase()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package it.vfsfitvnm.vimusic.ui.components.themed
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.utils.color
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
|
||||
@Composable
|
||||
inline fun Menu(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.width(256.dp)
|
||||
.background(
|
||||
color = colorPalette.elevatedBackground,
|
||||
shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
|
||||
)
|
||||
.padding(vertical = 8.dp),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
inline fun BasicMenu(
|
||||
noinline onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Menu(modifier = modifier) {
|
||||
MenuCloseButton(onClick = onDismiss)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun MenuEntry(
|
||||
@DrawableRes icon: Int,
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
secondaryText: String? = null,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
enabled = enabled,
|
||||
onClick = onClick
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(if (enabled) colorPalette.textSecondary else colorPalette.textDisabled),
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
)
|
||||
|
||||
Column {
|
||||
BasicText(
|
||||
text = text,
|
||||
style = typography.xs.semiBold.color(if (enabled) colorPalette.text else colorPalette.textDisabled)
|
||||
)
|
||||
|
||||
secondaryText?.let { secondaryText ->
|
||||
BasicText(
|
||||
text = secondaryText,
|
||||
style = typography.xxs.semiBold.color(if (enabled) colorPalette.textSecondary else colorPalette.textDisabled)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MenuIconButton(
|
||||
@DrawableRes icon: Int,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp, vertical = 16.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MenuCloseButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
MenuIconButton(
|
||||
icon = R.drawable.close,
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MenuBackButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
MenuIconButton(
|
||||
icon = R.drawable.chevron_back,
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package it.vfsfitvnm.vimusic.ui.components.themed
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import kotlin.random.Random
|
||||
|
||||
@Composable
|
||||
fun TextPlaceholder(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Spacer(
|
||||
modifier = modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.background(
|
||||
color = LocalColorPalette.current.darkGray,
|
||||
shape = RoundedCornerShape(0.dp)
|
||||
)
|
||||
.fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f })
|
||||
.height(16.dp)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user