Initial commit

This commit is contained in:
vfsfitvnm
2022-06-02 18:59:18 +02:00
commit 1e673ad582
160 changed files with 10800 additions and 0 deletions

View File

@@ -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!!)
}
}
)
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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
)

View File

@@ -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 }
)
)
}
}

View File

@@ -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()
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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,
)
}
}

View File

@@ -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
)
}

View File

@@ -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
)
}
}

View File

@@ -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()
}
)
}
}
}
}
}
}

View File

@@ -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
)
}

View File

@@ -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)
)
}