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)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import coil.compose.AsyncImage
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
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.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
|
||||
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.ui.views.SongItem
|
||||
import it.vfsfitvnm.vimusic.utils.*
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.*
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import it.vfsfitvnm.youtubemusic.YouTube
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun AlbumScreen(
|
||||
browseId: String,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
var album by remember {
|
||||
mutableStateOf<Outcome<YouTube.Album>>(Outcome.Loading)
|
||||
}
|
||||
|
||||
val onLoad = relaunchableEffect(Unit) {
|
||||
album = withContext(Dispatchers.IO) {
|
||||
YouTube.album(browseId)
|
||||
}
|
||||
}
|
||||
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
albumRoute { browseId ->
|
||||
AlbumScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
artistRoute { browseId ->
|
||||
ArtistScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
host {
|
||||
val density = LocalDensity.current
|
||||
val player = LocalYoutubePlayer.current
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
val (thumbnailSizeDp, thumbnailSizePx) = remember {
|
||||
density.run {
|
||||
128.dp to 128.dp.roundToPx()
|
||||
}
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = 72.dp)
|
||||
.background(colorPalette.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.height(52.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.chevron_back),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = pop)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ellipsis_horizontal),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
menuState.display {
|
||||
Menu {
|
||||
MenuCloseButton(onClick = menuState::hide)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.time,
|
||||
text = "Enqueue",
|
||||
enabled = player?.playbackState == Player.STATE_READY,
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
album.valueOrNull?.let { album ->
|
||||
player?.mediaController?.enqueue(album.items.mapNotNull { song ->
|
||||
song.toMediaItem(browseId, album)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.list,
|
||||
text = "Import as playlist",
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
|
||||
album.valueOrNull?.let { album ->
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.internal.runInTransaction {
|
||||
val playlistId = Database.insert(Playlist(name = album.title))
|
||||
|
||||
album.items.forEachIndexed { index, song ->
|
||||
song.toMediaItem(browseId, album)?.let { mediaItem ->
|
||||
if (Database.song(mediaItem.mediaId) == null) {
|
||||
Database.insert(mediaItem)
|
||||
}
|
||||
|
||||
Database.insert(
|
||||
SongInPlaylist(
|
||||
songId = mediaItem.mediaId,
|
||||
playlistId = playlistId,
|
||||
position = index
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
OutcomeItem(
|
||||
outcome = album,
|
||||
onRetry = onLoad,
|
||||
onLoading = {
|
||||
Loading()
|
||||
}
|
||||
) { album ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Max)
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = album.thumbnail.size(thumbnailSizePx),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(thumbnailSizeDp)
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Column {
|
||||
BasicText(
|
||||
text = album.title,
|
||||
style = typography.m.semiBold
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = "${album.authors.joinToString("") { it.name }} • ${album.year}",
|
||||
style = typography.xs.secondary.semiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.shuffle),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayFromBeginning(
|
||||
album.items.shuffled().mapNotNull { song ->
|
||||
song.toMediaItem(browseId, album)
|
||||
})
|
||||
}
|
||||
.shadow(elevation = 2.dp, shape = CircleShape)
|
||||
.background(color = colorPalette.elevatedBackground, shape = CircleShape)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.play),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayFromBeginning(album.items.mapNotNull { song ->
|
||||
song.toMediaItem(browseId, album)
|
||||
})
|
||||
}
|
||||
.shadow(elevation = 2.dp, shape = CircleShape)
|
||||
.background(color = colorPalette.elevatedBackground, shape = CircleShape)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
album.items.forEachIndexed { index, song ->
|
||||
SongItem(
|
||||
title = song.info.name,
|
||||
authors = (song.authors ?: album.authors).joinToString("") { it.name },
|
||||
durationText = song.durationText,
|
||||
onClick = {
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayAtIndex(album.items.mapNotNull { song ->
|
||||
song.toMediaItem(browseId, album)
|
||||
}, index)
|
||||
},
|
||||
startContent = {
|
||||
BasicText(
|
||||
text = "${index + 1}",
|
||||
style = typography.xs.secondary.bold.center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.width(36.dp)
|
||||
)
|
||||
},
|
||||
menuContent = {
|
||||
NonQueuedMediaItemMenu(
|
||||
mediaItem = song.toMediaItem(browseId, album) ?: return@SongItem,
|
||||
onDismiss = menuState::hide,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.shimmer()
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.height(IntrinsicSize.Max)
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.background(color = colorPalette.darkGray)
|
||||
.size(128.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Column {
|
||||
TextPlaceholder()
|
||||
|
||||
TextPlaceholder(
|
||||
modifier = Modifier
|
||||
.alpha(0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repeat(3) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.alpha(0.6f - it * 0.1f)
|
||||
.height(54.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(color = colorPalette.darkGray, shape = CircleShape)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
TextPlaceholder()
|
||||
|
||||
TextPlaceholder(
|
||||
modifier = Modifier
|
||||
.alpha(0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.ExpandableText
|
||||
import it.vfsfitvnm.vimusic.ui.components.Message
|
||||
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
|
||||
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.utils.*
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import it.vfsfitvnm.youtubemusic.YouTube
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun ArtistScreen(
|
||||
browseId: String,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
var artist by remember {
|
||||
mutableStateOf<Outcome<YouTube.Artist>>(Outcome.Loading)
|
||||
}
|
||||
|
||||
val onLoad = relaunchableEffect(Unit) {
|
||||
artist = withContext(Dispatchers.IO) {
|
||||
YouTube.artist(browseId)
|
||||
}
|
||||
}
|
||||
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
albumRoute { browseId ->
|
||||
AlbumScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
artistRoute { browseId ->
|
||||
ArtistScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
host {
|
||||
val density = LocalDensity.current
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
||||
val (thumbnailSizeDp, thumbnailSizePx) = remember {
|
||||
density.run {
|
||||
192.dp to 192.dp.roundToPx()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = 72.dp)
|
||||
.background(colorPalette.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.height(52.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.chevron_back),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = pop)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
OutcomeItem(
|
||||
outcome = artist,
|
||||
onRetry = onLoad,
|
||||
onLoading = {
|
||||
Loading()
|
||||
}
|
||||
) { artist ->
|
||||
AsyncImage(
|
||||
model = artist.thumbnail?.size(thumbnailSizePx),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.size(thumbnailSizeDp)
|
||||
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = artist.name,
|
||||
style = typography.l.semiBold,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.shuffle),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
YoutubePlayer.Radio.reset()
|
||||
artist.shuffleEndpoint?.let(YoutubePlayer.Radio::setup)
|
||||
}
|
||||
.shadow(elevation = 2.dp, shape = CircleShape)
|
||||
.background(color = colorPalette.elevatedBackground, shape = CircleShape)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.radio),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
YoutubePlayer.Radio.reset()
|
||||
artist.radioEndpoint?.let(YoutubePlayer.Radio::setup)
|
||||
}
|
||||
.shadow(elevation = 2.dp, shape = CircleShape)
|
||||
.background(color = colorPalette.elevatedBackground, shape = CircleShape)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
artist.description?.let { description ->
|
||||
ExpandableText(
|
||||
text = description,
|
||||
style = typography.xxs.secondary.align(TextAlign.Justify),
|
||||
minimizedMaxLines = 4,
|
||||
backgroundColor = colorPalette.background,
|
||||
showMoreTextStyle = typography.xxs.bold,
|
||||
modifier = Modifier
|
||||
.animateContentSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Message(
|
||||
text = "Page under construction",
|
||||
icon = R.drawable.sad,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.shimmer()
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.background(color = colorPalette.darkGray, shape = CircleShape)
|
||||
.size(192.dp)
|
||||
)
|
||||
|
||||
TextPlaceholder(
|
||||
modifier = Modifier
|
||||
.alpha(0.9f)
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
)
|
||||
|
||||
repeat(3) {
|
||||
TextPlaceholder(
|
||||
modifier = Modifier
|
||||
.alpha(0.8f)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.animation.*
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.media3.common.Player
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.route.rememberRoute
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.enums.SongCollection
|
||||
import it.vfsfitvnm.vimusic.models.Playlist
|
||||
import it.vfsfitvnm.vimusic.models.SearchQuery
|
||||
import it.vfsfitvnm.vimusic.models.SongWithInfo
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
|
||||
import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.*
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.ui.views.PlayerView
|
||||
import it.vfsfitvnm.vimusic.ui.views.PlaylistPreviewItem
|
||||
import it.vfsfitvnm.vimusic.ui.views.SongItem
|
||||
import it.vfsfitvnm.vimusic.utils.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun HomeScreen(intentVideoId: String?) {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
val intentVideoRoute = rememberIntentVideoRoute(intentVideoId)
|
||||
val playlistRoute = rememberLocalPlaylistRoute()
|
||||
val searchRoute = rememberSearchRoute()
|
||||
val searchResultRoute = rememberSearchResultRoute()
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
val (route, onRouteChanged) = rememberRoute(intentVideoId?.let { intentVideoRoute })
|
||||
|
||||
val playlistPreviews by remember {
|
||||
Database.playlistPreviews()
|
||||
}.collectAsState(initial = emptyList(), context = Dispatchers.IO)
|
||||
|
||||
val preferences = LocalPreferences.current
|
||||
|
||||
val songCollection by remember(preferences.homePageSongCollection) {
|
||||
when (preferences.homePageSongCollection) {
|
||||
SongCollection.MostPlayed -> Database.mostPlayed()
|
||||
SongCollection.Favorites -> Database.favorites()
|
||||
SongCollection.History -> Database.history()
|
||||
}
|
||||
}.collectAsState(initial = emptyList(), context = Dispatchers.IO)
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
RouteHandler(
|
||||
route = route,
|
||||
onRouteChanged = onRouteChanged,
|
||||
listenToGlobalEmitter = true
|
||||
) {
|
||||
intentVideoRoute { videoId ->
|
||||
IntentVideoScreen(
|
||||
videoId = videoId ?: error("videoId must be not null")
|
||||
)
|
||||
}
|
||||
|
||||
playlistRoute { playlistId ->
|
||||
LocalPlaylistScreen(
|
||||
playlistId = playlistId ?: error("playlistId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
searchResultRoute { query ->
|
||||
SearchResultScreen(
|
||||
query = query,
|
||||
onSearchAgain = {
|
||||
searchRoute(query)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
searchRoute { initialTextInput ->
|
||||
SearchScreen(
|
||||
initialTextInput = initialTextInput,
|
||||
onSearch = { query ->
|
||||
searchResultRoute(query)
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.insert(SearchQuery(query = query))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
albumRoute { browseId ->
|
||||
AlbumScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
artistRoute { browseId ->
|
||||
ArtistScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
host {
|
||||
val player = LocalYoutubePlayer.current
|
||||
val menuState = LocalMenuState.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
val thumbnailSize = remember {
|
||||
density.run {
|
||||
54.dp.roundToPx()
|
||||
}
|
||||
}
|
||||
|
||||
var isCreatingANewPlaylist by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (isCreatingANewPlaylist) {
|
||||
TextFieldDialog(
|
||||
hintText = "Enter the playlist name",
|
||||
onDismiss = {
|
||||
isCreatingANewPlaylist = false
|
||||
},
|
||||
onDone = { text ->
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.insert(Playlist(name = text))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
contentPadding = PaddingValues(bottom = 72.dp),
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.height(52.dp)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.search),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
searchRoute("")
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
BasicText(
|
||||
text = "Your playlists",
|
||||
style = typography.m.semiBold,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
LazyHorizontalGrid(
|
||||
rows = GridCells.Fixed(2),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
modifier = Modifier
|
||||
.height(248.dp)
|
||||
) {
|
||||
item {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(all = 8.dp)
|
||||
.width(108.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
isCreatingANewPlaylist = true
|
||||
}
|
||||
.background(colorPalette.lightBackground)
|
||||
.size(108.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.add),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(playlistPreviews) { playlistPreview ->
|
||||
PlaylistPreviewItem(
|
||||
playlistPreview = playlistPreview,
|
||||
modifier = Modifier
|
||||
.padding(all = 8.dp)
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
playlistRoute(playlistPreview.playlist.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.zIndex(1f)
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(top = 32.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
BasicText(
|
||||
text = when (preferences.homePageSongCollection) {
|
||||
SongCollection.MostPlayed -> "Most played"
|
||||
SongCollection.Favorites -> "Favorites"
|
||||
SongCollection.History -> "History"
|
||||
},
|
||||
style = typography.m.semiBold,
|
||||
modifier = Modifier
|
||||
.animateContentSize()
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.repeat),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.textSecondary),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
val values = SongCollection.values()
|
||||
|
||||
preferences.homePageSongCollection =
|
||||
values[(preferences.homePageSongCollection.ordinal + 1) % values.size]
|
||||
}
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
.size(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ellipsis_horizontal),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
menuState.display {
|
||||
BasicMenu(onDismiss = menuState::hide) {
|
||||
MenuEntry(
|
||||
icon = R.drawable.play,
|
||||
text = "Play",
|
||||
enabled = songCollection.isNotEmpty(),
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayFromBeginning(
|
||||
songCollection
|
||||
.map(SongWithInfo::asMediaItem)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.shuffle,
|
||||
text = "Shuffle",
|
||||
enabled = songCollection.isNotEmpty(),
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayFromBeginning(
|
||||
songCollection
|
||||
.shuffled()
|
||||
.map(SongWithInfo::asMediaItem)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.time,
|
||||
text = "Enqueue",
|
||||
enabled = songCollection.isNotEmpty() && player?.playbackState == Player.STATE_READY,
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
player?.mediaController?.enqueue(
|
||||
songCollection.map(SongWithInfo::asMediaItem)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items = songCollection,
|
||||
key = { _, song ->
|
||||
song.song.id
|
||||
}
|
||||
) { index, song ->
|
||||
SongItem(
|
||||
song = song,
|
||||
thumbnailSize = thumbnailSize,
|
||||
onClick = {
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayAtIndex(
|
||||
songCollection.map(SongWithInfo::asMediaItem),
|
||||
index
|
||||
)
|
||||
},
|
||||
menuContent = {
|
||||
when (preferences.homePageSongCollection) {
|
||||
SongCollection.MostPlayed -> NonQueuedMediaItemMenu(mediaItem = song.asMediaItem)
|
||||
SongCollection.Favorites -> InFavoritesMediaItemMenu(song = song)
|
||||
SongCollection.History -> InHistoryMediaItemMenu(song = song)
|
||||
}
|
||||
},
|
||||
onThumbnailContent = {
|
||||
AnimatedVisibility(
|
||||
visible = preferences.homePageSongCollection == SongCollection.MostPlayed,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
) {
|
||||
BasicText(
|
||||
text = song.song.formattedTotalPlayTime,
|
||||
style = typography.xxs.semiBold.center.color(Color.White),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.75f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlayerView(
|
||||
layoutState = rememberBottomSheetState(lowerBound = 64.dp, upperBound = maxHeight),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.MediaItem
|
||||
import com.valentinilk.shimmer.ShimmerBounds
|
||||
import com.valentinilk.shimmer.rememberShimmer
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.views.SongItem
|
||||
import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer
|
||||
import it.vfsfitvnm.vimusic.utils.asMediaItem
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlay
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import it.vfsfitvnm.youtubemusic.YouTube
|
||||
import it.vfsfitvnm.youtubemusic.toNullable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun IntentVideoScreen(videoId: String) {
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
albumRoute { browseId ->
|
||||
AlbumScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
artistRoute { browseId ->
|
||||
ArtistScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
host {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val density = LocalDensity.current
|
||||
val player = LocalYoutubePlayer.current
|
||||
|
||||
val mediaItem by produceState<Outcome<MediaItem>>(initialValue = Outcome.Loading) {
|
||||
value = withContext(Dispatchers.IO) {
|
||||
Database.songWithInfo(videoId)?.let { songWithInfo ->
|
||||
Outcome.Success(songWithInfo.asMediaItem)
|
||||
} ?: YouTube.getQueue(videoId).toNullable()
|
||||
?.map(YouTube.Item.Song::asMediaItem)
|
||||
?: Outcome.Error.Network
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
OutcomeItem(
|
||||
outcome = mediaItem,
|
||||
onLoading = {
|
||||
SmallSongItemShimmer(
|
||||
shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.View),
|
||||
thumbnailSizeDp = 54.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
) { mediaItem ->
|
||||
SongItem(
|
||||
mediaItem = mediaItem,
|
||||
thumbnailSize = remember {
|
||||
density.run {
|
||||
54.dp.roundToPx()
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
player?.mediaController?.forcePlay(mediaItem)
|
||||
pop()
|
||||
},
|
||||
menuContent = {
|
||||
NonQueuedMediaItemMenu(mediaItem = mediaItem)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import it.vfsfitvnm.reordering.rememberReorderingState
|
||||
import it.vfsfitvnm.reordering.verticalDragAfterLongPressToReorder
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.PlaylistWithSongs
|
||||
import it.vfsfitvnm.vimusic.models.SongInPlaylist
|
||||
import it.vfsfitvnm.vimusic.models.SongWithInfo
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.*
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.ui.views.SongItem
|
||||
import it.vfsfitvnm.vimusic.utils.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun LocalPlaylistScreen(
|
||||
playlistId: Long,
|
||||
) {
|
||||
val playlistWithSongs by remember(playlistId) {
|
||||
Database.playlistWithSongs(playlistId).map { it ?: PlaylistWithSongs.NotFound }
|
||||
}.collectAsState(initial = PlaylistWithSongs.Empty, context = Dispatchers.IO)
|
||||
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
albumRoute { browseId ->
|
||||
AlbumScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
artistRoute { browseId ->
|
||||
ArtistScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
host {
|
||||
val density = LocalDensity.current
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
val player = LocalYoutubePlayer.current
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
||||
val thumbnailSize = remember {
|
||||
density.run {
|
||||
54.dp.roundToPx()
|
||||
}
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val reorderingState = rememberReorderingState(playlistWithSongs.songs)
|
||||
|
||||
var isRenaming by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (isRenaming) {
|
||||
TextFieldDialog(
|
||||
hintText = "Enter the playlist name",
|
||||
initialTextInput = playlistWithSongs.playlist.name,
|
||||
onDismiss = {
|
||||
isRenaming = false
|
||||
},
|
||||
onDone = { text ->
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.update(playlistWithSongs.playlist.copy(name = text))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var isDeleting by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (isDeleting) {
|
||||
ConfirmationDialog(
|
||||
text = "Do you really want to delete this playlist?",
|
||||
onDismiss = {
|
||||
isDeleting = false
|
||||
},
|
||||
onConfirm = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.delete(playlistWithSongs.playlist)
|
||||
}
|
||||
pop()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
contentPadding = PaddingValues(bottom = 64.dp),
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.height(52.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.chevron_back),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = pop)
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ellipsis_horizontal),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
menuState.display {
|
||||
Menu {
|
||||
MenuCloseButton(onClick = menuState::hide)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.time,
|
||||
text = "Enqueue",
|
||||
enabled = playlistWithSongs.songs.isNotEmpty() && player?.playbackState == Player.STATE_READY,
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
player?.mediaController?.enqueue(
|
||||
playlistWithSongs.songs.map(
|
||||
SongWithInfo::asMediaItem
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.pencil,
|
||||
text = "Rename",
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
isRenaming = true
|
||||
}
|
||||
)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.trash,
|
||||
text = "Delete",
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
isDeleting = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp, bottom = 32.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
BasicText(
|
||||
text = playlistWithSongs.playlist.name,
|
||||
style = typography.m.semiBold
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = "${playlistWithSongs.songs.size} songs",
|
||||
style = typography.xxs.semiBold.secondary
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.shuffle),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayFromBeginning(
|
||||
playlistWithSongs.songs
|
||||
.map(SongWithInfo::asMediaItem)
|
||||
.shuffled()
|
||||
)
|
||||
}
|
||||
.shadow(elevation = 2.dp, shape = CircleShape)
|
||||
.background(
|
||||
color = colorPalette.elevatedBackground,
|
||||
shape = CircleShape
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.play),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayFromBeginning(
|
||||
playlistWithSongs.songs.map(
|
||||
SongWithInfo::asMediaItem
|
||||
)
|
||||
)
|
||||
}
|
||||
.shadow(elevation = 2.dp, shape = CircleShape)
|
||||
.background(
|
||||
color = colorPalette.elevatedBackground,
|
||||
shape = CircleShape
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
itemsIndexed(items = playlistWithSongs.songs, key = { _, song -> song.song.id }) { index, song ->
|
||||
SongItem(
|
||||
song = song,
|
||||
thumbnailSize = thumbnailSize,
|
||||
onClick = {
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayAtIndex(
|
||||
playlistWithSongs.songs.map(
|
||||
SongWithInfo::asMediaItem
|
||||
), index
|
||||
)
|
||||
},
|
||||
menuContent = {
|
||||
InPlaylistMediaItemMenu(
|
||||
playlistId = playlistId,
|
||||
positionInPlaylist = index,
|
||||
song = song
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.verticalDragAfterLongPressToReorder(
|
||||
reorderingState = reorderingState,
|
||||
index = index,
|
||||
onDragStart = {
|
||||
hapticFeedback.performHapticFeedback(
|
||||
HapticFeedbackType.LongPress
|
||||
)
|
||||
},
|
||||
onDragEnd = { reachedIndex ->
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
if (index > reachedIndex) {
|
||||
Database.incrementSongPositions(
|
||||
playlistId = playlistWithSongs.playlist.id,
|
||||
fromPosition = reachedIndex,
|
||||
toPosition = index - 1
|
||||
)
|
||||
} else if (index < reachedIndex) {
|
||||
Database.decrementSongPositions(
|
||||
playlistId = playlistWithSongs.playlist.id,
|
||||
fromPosition = index + 1,
|
||||
toPosition = reachedIndex
|
||||
)
|
||||
}
|
||||
|
||||
Database.update(
|
||||
SongInPlaylist(
|
||||
songId = playlistWithSongs.songs[index].song.id,
|
||||
playlistId = playlistWithSongs.playlist.id,
|
||||
position = reachedIndex
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.valentinilk.shimmer.Shimmer
|
||||
import com.valentinilk.shimmer.ShimmerBounds
|
||||
import com.valentinilk.shimmer.rememberShimmer
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.*
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.ui.views.SongItem
|
||||
import it.vfsfitvnm.vimusic.utils.*
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import it.vfsfitvnm.youtubemusic.YouTube
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun SearchResultScreen(
|
||||
query: String,
|
||||
onSearchAgain: () -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
val preferences = LocalPreferences.current
|
||||
val player = LocalYoutubePlayer.current
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
var continuation by remember(preferences.searchFilter) {
|
||||
mutableStateOf<Outcome<String?>>(Outcome.Initial)
|
||||
}
|
||||
|
||||
val items = remember(preferences.searchFilter) {
|
||||
mutableStateListOf<YouTube.Item>()
|
||||
}
|
||||
|
||||
val onLoad = relaunchableEffect(preferences.searchFilter) {
|
||||
withContext(Dispatchers.Main) {
|
||||
val token = continuation.valueOrNull
|
||||
|
||||
continuation = Outcome.Loading
|
||||
|
||||
continuation = withContext(Dispatchers.IO) {
|
||||
YouTube.search(query, preferences.searchFilter, token)
|
||||
}.map { searchResult ->
|
||||
items.addAll(searchResult.items)
|
||||
searchResult.continuation
|
||||
}.recoverWith(token)
|
||||
}
|
||||
}
|
||||
|
||||
val thumbnailSizePx = remember {
|
||||
density.run {
|
||||
54.dp.roundToPx()
|
||||
}
|
||||
}
|
||||
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
RouteHandler(
|
||||
listenToGlobalEmitter = true
|
||||
) {
|
||||
albumRoute { browseId ->
|
||||
AlbumScreen(
|
||||
browseId = browseId ?: "browseId cannot be null"
|
||||
)
|
||||
}
|
||||
|
||||
artistRoute { browseId ->
|
||||
ArtistScreen(
|
||||
browseId = browseId ?: "browseId cannot be null"
|
||||
)
|
||||
}
|
||||
|
||||
host {
|
||||
val shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.Window)
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
contentPadding = PaddingValues(bottom = 64.dp),
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.height(52.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.chevron_back),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = pop)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = query,
|
||||
style = typography.m.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onSearchAgain
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ChipGroup(
|
||||
items = listOf(
|
||||
ChipItem(
|
||||
text = "Songs",
|
||||
value = YouTube.Item.Song.Filter.value
|
||||
),
|
||||
ChipItem(
|
||||
text = "Albums",
|
||||
value = YouTube.Item.Album.Filter.value
|
||||
),
|
||||
ChipItem(
|
||||
text = "Artists",
|
||||
value = YouTube.Item.Artist.Filter.value
|
||||
),
|
||||
ChipItem(
|
||||
text = "Videos",
|
||||
value = YouTube.Item.Video.Filter.value
|
||||
),
|
||||
),
|
||||
value = preferences.searchFilter,
|
||||
selectedBackgroundColor = colorPalette.primaryContainer,
|
||||
unselectedBackgroundColor = colorPalette.lightBackground,
|
||||
selectedTextStyle = typography.xs.medium.color(colorPalette.onPrimaryContainer),
|
||||
unselectedTextStyle = typography.xs.medium,
|
||||
shape = RoundedCornerShape(36.dp),
|
||||
onValueChanged = { filter ->
|
||||
preferences.searchFilter = filter
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items(items) { item ->
|
||||
SmallItem(
|
||||
item = item,
|
||||
thumbnailSizeDp = 54.dp,
|
||||
thumbnailSizePx = thumbnailSizePx,
|
||||
onClick = {
|
||||
when (item) {
|
||||
is YouTube.Item.Album -> albumRoute(item.info.endpoint!!.browseId)
|
||||
is YouTube.Item.Artist -> artistRoute(item.info.endpoint!!.browseId)
|
||||
is YouTube.Item.Song -> {
|
||||
player?.mediaController?.forcePlay(item.asMediaItem)
|
||||
item.info.endpoint?.let(YoutubePlayer.Radio::setup)
|
||||
}
|
||||
is YouTube.Item.Video -> {
|
||||
player?.mediaController?.forcePlay(item.asMediaItem)
|
||||
item.info.endpoint?.let(YoutubePlayer.Radio::setup)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
when (val currentResult = continuation) {
|
||||
is Outcome.Error -> item {
|
||||
Error(
|
||||
error = currentResult,
|
||||
onRetry = onLoad,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
is Outcome.Recovered -> item {
|
||||
Error(
|
||||
error = currentResult.error,
|
||||
onRetry = onLoad,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
is Outcome.Success -> {
|
||||
if (items.isEmpty()) {
|
||||
item {
|
||||
Message(
|
||||
text = "No results found",
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentResult.value != null) {
|
||||
item {
|
||||
SideEffect(onLoad)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
if (continuation is Outcome.Loading || (continuation is Outcome.Success && continuation.valueOrNull != null)) {
|
||||
items(count = if (items.isEmpty()) 8 else 3, key = { it }) { index ->
|
||||
when (preferences.searchFilter) {
|
||||
YouTube.Item.Artist.Filter.value -> SmallArtistItemShimmer(
|
||||
shimmer = shimmer,
|
||||
thumbnailSizeDp = 54.dp,
|
||||
modifier = Modifier
|
||||
.alpha(1f - index * 0.125f)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
)
|
||||
else -> SmallSongItemShimmer(
|
||||
shimmer = shimmer,
|
||||
thumbnailSizeDp = 54.dp,
|
||||
modifier = Modifier
|
||||
.alpha(1f - index * 0.125f)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SmallSongItemShimmer(
|
||||
shimmer: Shimmer,
|
||||
thumbnailSizeDp: Dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
.shimmer(shimmer)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.background(colorPalette.darkGray)
|
||||
.size(thumbnailSizeDp)
|
||||
)
|
||||
|
||||
Column {
|
||||
TextPlaceholder()
|
||||
TextPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SmallArtistItemShimmer(
|
||||
shimmer: Shimmer,
|
||||
thumbnailSizeDp: Dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
.shimmer(shimmer)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.background(color = colorPalette.darkGray, shape = CircleShape)
|
||||
.size(thumbnailSizeDp)
|
||||
)
|
||||
|
||||
TextPlaceholder()
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun SmallItem(
|
||||
item: YouTube.Item,
|
||||
thumbnailSizeDp: Dp,
|
||||
thumbnailSizePx: Int,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (item) {
|
||||
is YouTube.Item.Artist -> SmallArtistItem(
|
||||
artist = item,
|
||||
thumbnailSizeDp = thumbnailSizeDp,
|
||||
thumbnailSizePx = thumbnailSizePx,
|
||||
modifier = modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = onClick
|
||||
)
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
)
|
||||
is YouTube.Item.Song -> SmallSongItem(
|
||||
song = item,
|
||||
thumbnailSizePx = thumbnailSizePx,
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
)
|
||||
is YouTube.Item.Album -> SmallAlbumItem(
|
||||
album = item,
|
||||
thumbnailSizeDp = thumbnailSizeDp,
|
||||
thumbnailSizePx = thumbnailSizePx,
|
||||
modifier = modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = onClick
|
||||
)
|
||||
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
)
|
||||
is YouTube.Item.Video -> SmallVideoItem(
|
||||
video = item,
|
||||
thumbnailSizePx = thumbnailSizePx,
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun SmallSongItem(
|
||||
song: YouTube.Item.Song,
|
||||
thumbnailSizePx: Int,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SongItem(
|
||||
thumbnailModel = song.thumbnail.size(thumbnailSizePx),
|
||||
title = song.info.name,
|
||||
authors = song.authors.joinToString("") { it.name },
|
||||
durationText = song.durationText,
|
||||
onClick = onClick,
|
||||
menuContent = {
|
||||
NonQueuedMediaItemMenu(mediaItem = song.asMediaItem)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun SmallVideoItem(
|
||||
video: YouTube.Item.Video,
|
||||
thumbnailSizePx: Int,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SongItem(
|
||||
thumbnailModel = video.thumbnail.size(thumbnailSizePx),
|
||||
title = video.info.name,
|
||||
authors = video.views.joinToString("") { it.name },
|
||||
durationText = video.durationText,
|
||||
onClick = onClick,
|
||||
menuContent = {
|
||||
NonQueuedMediaItemMenu(mediaItem = video.asMediaItem)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SmallAlbumItem(
|
||||
album: YouTube.Item.Album,
|
||||
thumbnailSizeDp: Dp,
|
||||
thumbnailSizePx: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val typography = LocalTypography.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
) {
|
||||
AsyncImage(
|
||||
model = album.thumbnail.size(thumbnailSizePx),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(thumbnailSizeDp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
BasicText(
|
||||
text = album.info.name,
|
||||
style = typography.xs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
BasicText(
|
||||
text = "${album.authors.joinToString("") { it.name }} • ${album.year}",
|
||||
style = typography.xs,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SmallArtistItem(
|
||||
artist: YouTube.Item.Artist,
|
||||
thumbnailSizeDp: Dp,
|
||||
thumbnailSizePx: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val typography = LocalTypography.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
) {
|
||||
AsyncImage(
|
||||
model = artist.thumbnail.size(thumbnailSizePx),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.size(thumbnailSizeDp)
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = artist.info.name,
|
||||
style = typography.xs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.foundation.verticalScroll
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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 it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
|
||||
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import it.vfsfitvnm.youtubemusic.YouTube
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
initialTextInput: String,
|
||||
onSearch: (String) -> Unit
|
||||
) {
|
||||
var textFieldValue by rememberSaveable(initialTextInput, stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(
|
||||
TextFieldValue(
|
||||
text = initialTextInput,
|
||||
selection = TextRange(initialTextInput.length)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val focusRequester = remember {
|
||||
FocusRequester()
|
||||
}
|
||||
|
||||
val searchSuggestions by produceState<Outcome<List<String>?>>(
|
||||
initialValue = Outcome.Initial,
|
||||
key1 = textFieldValue
|
||||
) {
|
||||
value = if (textFieldValue.text.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
YouTube.getSearchSuggestions(textFieldValue.text)
|
||||
}
|
||||
} else {
|
||||
Outcome.Initial
|
||||
}
|
||||
}
|
||||
|
||||
val history by remember(textFieldValue.text) {
|
||||
Database.getRecentQueries("%${textFieldValue.text}%").distinctUntilChanged { old, new ->
|
||||
old.size == new.size
|
||||
}
|
||||
}.collectAsState(initial = null, context = Dispatchers.IO)
|
||||
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
albumRoute { browseId ->
|
||||
AlbumScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
artistRoute { browseId ->
|
||||
ArtistScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
host {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.height(52.dp)
|
||||
) {
|
||||
BasicTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = {
|
||||
textFieldValue = it
|
||||
},
|
||||
textStyle = typography.m.medium,
|
||||
singleLine = true,
|
||||
maxLines = 1,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSearch = {
|
||||
if (textFieldValue.text.isNotEmpty()) {
|
||||
onSearch(textFieldValue.text)
|
||||
}
|
||||
}
|
||||
),
|
||||
cursorBrush = SolidColor(colorPalette.text),
|
||||
decorationBox = { innerTextField ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.chevron_back),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
pop()
|
||||
focusRequester.freeFocus()
|
||||
}
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = textFieldValue.text.isEmpty(),
|
||||
enter = fadeIn(tween(100)),
|
||||
exit = fadeOut(tween(100)),
|
||||
) {
|
||||
BasicText(
|
||||
text = "Enter a song, an album, an artist name...",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = typography.m.secondary,
|
||||
)
|
||||
}
|
||||
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 64.dp)
|
||||
) {
|
||||
history?.forEach { searchQuery ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
onSearch(searchQuery.query)
|
||||
}
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp, horizontal = 8.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.time),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.darkGray),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = searchQuery.query,
|
||||
style = typography.s.secondary,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.close),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.darkGray),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.delete(searchQuery)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.arrow_forward),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.darkGray),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
textFieldValue = TextFieldValue(
|
||||
text = searchQuery.query,
|
||||
selection = TextRange(searchQuery.query.length)
|
||||
)
|
||||
}
|
||||
.rotate(225f)
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutcomeItem(
|
||||
outcome = searchSuggestions
|
||||
) { suggestions ->
|
||||
suggestions?.forEach { suggestion ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
onSearch(suggestion)
|
||||
}
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp, horizontal = 8.dp)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = suggestion,
|
||||
style = typography.s.secondary,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.arrow_forward),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.darkGray),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
textFieldValue = TextFieldValue(
|
||||
text = suggestion,
|
||||
selection = TextRange(suggestion.length)
|
||||
)
|
||||
}
|
||||
.rotate(225f)
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import it.vfsfitvnm.route.Route0
|
||||
import it.vfsfitvnm.route.Route1
|
||||
|
||||
@Composable
|
||||
fun rememberIntentVideoRoute(intentVideoId: String?): Route1<String?> {
|
||||
val videoId = rememberSaveable {
|
||||
mutableStateOf(intentVideoId)
|
||||
}
|
||||
return remember {
|
||||
Route1("rememberIntentVideoRoute", videoId)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberAlbumRoute(): Route1<String?> {
|
||||
val browseId = rememberSaveable {
|
||||
mutableStateOf<String?>(null)
|
||||
}
|
||||
return remember {
|
||||
Route1("AlbumRoute", browseId)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberArtistRoute(): Route1<String?> {
|
||||
val browseId = rememberSaveable {
|
||||
mutableStateOf<String?>(null)
|
||||
}
|
||||
return remember {
|
||||
Route1("ArtistRoute", browseId)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberLocalPlaylistRoute(): Route1<Long?> {
|
||||
val playlistType = rememberSaveable {
|
||||
mutableStateOf<Long?>(null)
|
||||
}
|
||||
return remember {
|
||||
Route1("LocalPlaylistRoute", playlistType)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSearchRoute(): Route1<String> {
|
||||
val initialTextInput = remember {
|
||||
mutableStateOf("")
|
||||
}
|
||||
return remember {
|
||||
Route1("SearchRoute", initialTextInput)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberCreatePlaylistRoute(): Route0 {
|
||||
return remember {
|
||||
Route0("CreatePlaylistRoute")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSearchResultRoute(): Route1<String> {
|
||||
val searchQuery = rememberSaveable {
|
||||
mutableStateOf("")
|
||||
}
|
||||
return remember {
|
||||
Route1("SearchResultRoute", searchQuery)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberLyricsRoute(): Route0 {
|
||||
return remember {
|
||||
Route0("LyricsRoute")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package it.vfsfitvnm.vimusic.ui.styling
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@Immutable
|
||||
data class ColorPalette(
|
||||
val background: Color,
|
||||
val elevatedBackground: Color,
|
||||
val lightBackground: Color,
|
||||
val text: Color,
|
||||
val textSecondary: Color,
|
||||
val textDisabled: Color,
|
||||
val lightGray: Color,
|
||||
val gray: Color,
|
||||
val darkGray: Color,
|
||||
val blue: Color,
|
||||
val red: Color,
|
||||
val green: Color,
|
||||
val orange: Color,
|
||||
|
||||
val primaryContainer: Color,
|
||||
val onPrimaryContainer: Color,
|
||||
val iconOnPrimaryContainer: Color,
|
||||
)
|
||||
|
||||
val DarkColorPalette = ColorPalette(
|
||||
background = Color(0xff16171d),
|
||||
lightBackground = Color(0xff1f2029),
|
||||
elevatedBackground = Color(0xff1f2029),
|
||||
text = Color(0xffe1e1e2),
|
||||
textSecondary = Color(0xffa3a4a6),
|
||||
textDisabled = Color(0xff6f6f73),
|
||||
lightGray = Color(0xfff8f8f8),
|
||||
gray = Color(0xFFE5E5E5),
|
||||
darkGray = Color(0xFF838383),
|
||||
blue = Color(0xff4046bf),
|
||||
red = Color(0xffbf4040),
|
||||
green = Color(0xff7fbf40),
|
||||
orange = Color(0xffe8820e),
|
||||
|
||||
primaryContainer = Color(0xff4046bf),
|
||||
onPrimaryContainer = Color.White,
|
||||
iconOnPrimaryContainer = Color.White,
|
||||
)
|
||||
|
||||
val LightColorPalette = ColorPalette(
|
||||
background = Color(0xfffdfdfe),
|
||||
lightBackground = Color(0xFFf8f8fc),
|
||||
elevatedBackground = Color(0xfffdfdfe),
|
||||
lightGray = Color(0xfff8f8f8),
|
||||
gray = Color(0xFFE5E5E5),
|
||||
darkGray = Color(0xFF838383),
|
||||
text = Color(0xff212121),
|
||||
textSecondary = Color(0xFF656566),
|
||||
textDisabled = Color(0xFF9d9d9d),
|
||||
blue = Color(0xff4059bf),
|
||||
red = Color(0xffbf4040),
|
||||
green = Color(0xff7fbf40),
|
||||
orange = Color(0xffe8730e),
|
||||
|
||||
primaryContainer = Color(0xff4046bf),
|
||||
onPrimaryContainer = Color.White,
|
||||
iconOnPrimaryContainer = Color.White,
|
||||
// primaryContainer = Color(0xffecedf9),
|
||||
// onPrimaryContainer = Color(0xff121212),
|
||||
// iconOnPrimaryContainer = Color(0xff2e30b8),
|
||||
)
|
||||
|
||||
val LocalColorPalette = staticCompositionLocalOf { LightColorPalette }
|
||||
|
||||
@Composable
|
||||
fun rememberColorPalette(isDarkTheme: Boolean = isSystemInDarkTheme()): ColorPalette {
|
||||
return remember(isDarkTheme) {
|
||||
if (isDarkTheme) DarkColorPalette else LightColorPalette
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package it.vfsfitvnm.vimusic.ui.styling
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
import androidx.compose.ui.text.PlatformTextStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
|
||||
@Immutable
|
||||
data class Typography(
|
||||
val xxs: TextStyle,
|
||||
val xs: TextStyle,
|
||||
val s: TextStyle,
|
||||
val m: TextStyle,
|
||||
val l: TextStyle,
|
||||
)
|
||||
|
||||
val LocalTypography = staticCompositionLocalOf<Typography> { TODO() }
|
||||
|
||||
@ExperimentalTextApi
|
||||
@Composable
|
||||
fun rememberTypography(color: Color): Typography {
|
||||
return remember(color) {
|
||||
TextStyle(
|
||||
fontFamily = FontFamily(
|
||||
Font(
|
||||
resId = R.font.poppins_w300,
|
||||
weight = FontWeight.Light
|
||||
),
|
||||
Font(
|
||||
resId = R.font.poppins_w400,
|
||||
weight = FontWeight.Normal
|
||||
),
|
||||
Font(
|
||||
resId = R.font.poppins_w400_italic,
|
||||
weight = FontWeight.Normal,
|
||||
style = FontStyle.Italic
|
||||
),
|
||||
Font(
|
||||
resId = R.font.poppins_w500,
|
||||
weight = FontWeight.Medium
|
||||
),
|
||||
Font(
|
||||
resId = R.font.poppins_w600,
|
||||
weight = FontWeight.SemiBold
|
||||
),
|
||||
Font(
|
||||
resId = R.font.poppins_w700,
|
||||
weight = FontWeight.Bold
|
||||
),
|
||||
),
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = color,
|
||||
platformStyle = PlatformTextStyle(includeFontPadding = false)
|
||||
).run {
|
||||
Typography(
|
||||
xxs = copy(fontSize = 12.sp),
|
||||
xs = copy(fontSize = 14.sp),
|
||||
s = copy(fontSize = 16.sp),
|
||||
m = copy(fontSize = 18.sp),
|
||||
l = copy(fontSize = 20.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package it.vfsfitvnm.vimusic.ui.views
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import com.valentinilk.shimmer.ShimmerBounds
|
||||
import com.valentinilk.shimmer.rememberShimmer
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.BottomSheetState
|
||||
import it.vfsfitvnm.vimusic.ui.components.Error
|
||||
import it.vfsfitvnm.vimusic.ui.components.MusicBars
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu
|
||||
import it.vfsfitvnm.vimusic.ui.screens.SmallSongItemShimmer
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LightColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer
|
||||
import it.vfsfitvnm.vimusic.utils.YoutubePlayer
|
||||
import it.vfsfitvnm.reordering.rememberReorderingState
|
||||
import it.vfsfitvnm.reordering.verticalDragAfterLongPressToReorder
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun CurrentPlaylistView(
|
||||
layoutState: BottomSheetState,
|
||||
onGlobalRouteEmitted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val density = LocalDensity.current
|
||||
val player = LocalYoutubePlayer.current
|
||||
val colorPalette = LocalColorPalette.current
|
||||
|
||||
val thumbnailSize = remember {
|
||||
density.run {
|
||||
54.dp.roundToPx()
|
||||
}
|
||||
}
|
||||
|
||||
val isPaused by derivedStateOf {
|
||||
player?.playbackState == Player.STATE_ENDED || player?.playWhenReady == false
|
||||
}
|
||||
|
||||
val shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.Window)
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val lazyListState =
|
||||
rememberLazyListState(initialFirstVisibleItemIndex = player?.mediaItemIndex ?: 0)
|
||||
|
||||
val reorderingState = rememberReorderingState(player?.mediaItems ?: emptyList())
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
modifier = modifier
|
||||
.nestedScroll(remember {
|
||||
layoutState.nestedScrollConnection(player?.mediaItemIndex == 0)
|
||||
})
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = player?.mediaItems ?: emptyList()
|
||||
) { index, mediaItem ->
|
||||
val isPlayingThisMediaItem by derivedStateOf {
|
||||
player?.mediaItemIndex == index
|
||||
}
|
||||
|
||||
SongItem(
|
||||
mediaItem = mediaItem,
|
||||
thumbnailSize = thumbnailSize,
|
||||
onClick = {
|
||||
if (isPlayingThisMediaItem) {
|
||||
if (isPaused) {
|
||||
player?.mediaController?.play()
|
||||
} else {
|
||||
player?.mediaController?.pause()
|
||||
}
|
||||
} else {
|
||||
player?.mediaController?.playWhenReady = true
|
||||
player?.mediaController?.seekToDefaultPosition(index)
|
||||
}
|
||||
},
|
||||
menuContent = {
|
||||
QueuedMediaItemMenu(
|
||||
mediaItem = mediaItem,
|
||||
indexInQueue = index,
|
||||
onGlobalRouteEmitted = onGlobalRouteEmitted
|
||||
)
|
||||
},
|
||||
onThumbnailContent = {
|
||||
AnimatedVisibility(
|
||||
visible = isPlayingThisMediaItem,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.background(Color.Black.copy(alpha = 0.25f))
|
||||
.size(54.dp)
|
||||
) {
|
||||
if (isPaused) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.play),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(LightColorPalette.background),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
)
|
||||
} else {
|
||||
MusicBars(
|
||||
color = LightColorPalette.background,
|
||||
// shape = RectangleShape,
|
||||
modifier = Modifier
|
||||
.height(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundColor = colorPalette.elevatedBackground,
|
||||
modifier = Modifier
|
||||
.verticalDragAfterLongPressToReorder(
|
||||
reorderingState = reorderingState,
|
||||
index = index,
|
||||
onDragStart = {
|
||||
hapticFeedback.performHapticFeedback(
|
||||
HapticFeedbackType.LongPress
|
||||
)
|
||||
},
|
||||
onDragEnd = { reachedIndex ->
|
||||
player?.mediaController?.moveMediaItem(index, reachedIndex)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (YoutubePlayer.Radio.isActive && player != null) {
|
||||
when (val nextContinuation = YoutubePlayer.Radio.nextContinuation) {
|
||||
is Outcome.Loading, is Outcome.Success<*> -> {
|
||||
if (nextContinuation is Outcome.Success<*>) {
|
||||
item {
|
||||
SideEffect {
|
||||
coroutineScope.launch {
|
||||
YoutubePlayer.Radio.process(
|
||||
player.mediaController,
|
||||
force = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(count = 3, key = { it }) { index ->
|
||||
SmallSongItemShimmer(
|
||||
shimmer = shimmer,
|
||||
thumbnailSizeDp = 54.dp,
|
||||
modifier = Modifier
|
||||
.alpha(1f - index * 0.125f)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
is Outcome.Error -> item {
|
||||
Error(
|
||||
error = nextContinuation
|
||||
)
|
||||
}
|
||||
is Outcome.Recovered<*> -> item {
|
||||
Error(
|
||||
error = nextContinuation.error,
|
||||
onRetry = {
|
||||
coroutineScope.launch {
|
||||
YoutubePlayer.Radio.process(player.mediaController, force = true)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package it.vfsfitvnm.vimusic.ui.views
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.with
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import it.vfsfitvnm.route.Route
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.route.empty
|
||||
import it.vfsfitvnm.route.rememberRoute
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.BottomSheet
|
||||
import it.vfsfitvnm.vimusic.ui.components.BottomSheetState
|
||||
import it.vfsfitvnm.vimusic.ui.components.Message
|
||||
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
|
||||
import it.vfsfitvnm.vimusic.ui.screens.rememberLyricsRoute
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer
|
||||
import it.vfsfitvnm.vimusic.utils.center
|
||||
import it.vfsfitvnm.vimusic.utils.color
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import it.vfsfitvnm.youtubemusic.YouTube
|
||||
import it.vfsfitvnm.youtubemusic.isEvaluable
|
||||
import it.vfsfitvnm.youtubemusic.toNotNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun PlayerBottomSheet(
|
||||
layoutState: BottomSheetState,
|
||||
onGlobalRouteEmitted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val player = LocalYoutubePlayer.current ?: return
|
||||
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val lyricsRoute = rememberLyricsRoute()
|
||||
|
||||
var route by rememberRoute()
|
||||
|
||||
var nextOutcome by remember(player.mediaItem!!.mediaId) {
|
||||
mutableStateOf<Outcome<YouTube.NextResult>>(Outcome.Initial)
|
||||
}
|
||||
|
||||
var lyricsOutcome by remember(player.mediaItem!!.mediaId) {
|
||||
mutableStateOf<Outcome<String?>>(Outcome.Initial)
|
||||
}
|
||||
|
||||
BottomSheet(
|
||||
state = layoutState,
|
||||
peekHeight = 128.dp,
|
||||
elevation = 16.dp,
|
||||
shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp),
|
||||
handleOutsideInteractionsWhenExpanded = true,
|
||||
modifier = modifier,
|
||||
collapsedContent = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(layoutState.lowerBound)
|
||||
.background(colorPalette.elevatedBackground)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.background(color = colorPalette.textDisabled, shape = RoundedCornerShape(16.dp))
|
||||
.width(36.dp)
|
||||
.height(4.dp)
|
||||
.padding(top = 8.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
) {
|
||||
@Composable
|
||||
fun Element(
|
||||
text: String,
|
||||
targetRoute: Route?
|
||||
) {
|
||||
val color by animateColorAsState(
|
||||
if (targetRoute == route) {
|
||||
colorPalette.text
|
||||
} else {
|
||||
colorPalette.textDisabled
|
||||
}
|
||||
)
|
||||
|
||||
val scale by animateFloatAsState(
|
||||
if (targetRoute == route) {
|
||||
1f
|
||||
} else {
|
||||
0.9f
|
||||
}
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = text,
|
||||
style = typography.xs.medium.color(color).center,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
route = targetRoute
|
||||
coroutineScope.launch(Dispatchers.Main) {
|
||||
layoutState.expand()
|
||||
}
|
||||
}
|
||||
.padding(vertical = 8.dp)
|
||||
.scale(scale)
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Element(
|
||||
text = "UP NEXT",
|
||||
targetRoute = null
|
||||
)
|
||||
|
||||
Element(
|
||||
text = "LYRICS",
|
||||
targetRoute = lyricsRoute
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
RouteHandler(
|
||||
route = route,
|
||||
onRouteChanged = {
|
||||
route = it
|
||||
},
|
||||
handleBackPress = false,
|
||||
transitionSpec = {
|
||||
when (targetState.route) {
|
||||
lyricsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Left) with
|
||||
slideOutOfContainer(AnimatedContentScope.SlideDirection.Left)
|
||||
else -> when (initialState.route) {
|
||||
lyricsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Right) with
|
||||
slideOutOfContainer(AnimatedContentScope.SlideDirection.Right)
|
||||
else -> empty
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.background(colorPalette.elevatedBackground)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
lyricsRoute {
|
||||
OutcomeItem(
|
||||
outcome = lyricsOutcome,
|
||||
onInitialize = {
|
||||
lyricsOutcome = Outcome.Loading
|
||||
|
||||
coroutineScope.launch(Dispatchers.Main) {
|
||||
if (nextOutcome.isEvaluable) {
|
||||
nextOutcome = Outcome.Loading
|
||||
nextOutcome = withContext(Dispatchers.IO) {
|
||||
YouTube.next(
|
||||
player.mediaItem!!.mediaId,
|
||||
player.mediaItem!!.mediaMetadata.extras?.getString("playlistId"),
|
||||
player.mediaItemIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lyricsOutcome = nextOutcome.flatMap {
|
||||
it.lyrics?.text().toNotNull()
|
||||
}
|
||||
}
|
||||
},
|
||||
onLoading = {
|
||||
LyricsShimmer(
|
||||
modifier = Modifier
|
||||
.shimmer()
|
||||
)
|
||||
}
|
||||
) { lyrics ->
|
||||
if (lyrics != null) {
|
||||
BasicText(
|
||||
text = lyrics,
|
||||
style = typography.xs.center,
|
||||
modifier = Modifier
|
||||
.padding(top = 64.dp)
|
||||
.nestedScroll(remember { layoutState.nestedScrollConnection() })
|
||||
.verticalScroll(rememberScrollState())
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
.padding(horizontal = 48.dp)
|
||||
)
|
||||
} else {
|
||||
Message(
|
||||
text = "Lyrics not available",
|
||||
icon = R.drawable.text,
|
||||
modifier = Modifier
|
||||
.padding(top = 64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
host {
|
||||
CurrentPlaylistView(
|
||||
layoutState = layoutState,
|
||||
onGlobalRouteEmitted = onGlobalRouteEmitted,
|
||||
modifier = Modifier
|
||||
.padding(top = 64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LyricsShimmer(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
) {
|
||||
repeat(16) { index ->
|
||||
TextPlaceholder(
|
||||
modifier = Modifier
|
||||
.alpha(1f - index * 0.05f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
466
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt
Normal file
466
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt
Normal file
@@ -0,0 +1,466 @@
|
||||
package it.vfsfitvnm.vimusic.ui.views
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.ui.DefaultTimeBar
|
||||
import androidx.media3.ui.TimeBar
|
||||
import coil.compose.AsyncImage
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.*
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.utils.*
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun PlayerView(
|
||||
layoutState: BottomSheetState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val menuState = LocalMenuState.current
|
||||
val preferences = LocalPreferences.current
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val player = LocalYoutubePlayer.current
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
player?.mediaItem ?: return
|
||||
|
||||
val smallThumbnailSize = remember {
|
||||
density.run { 64.dp.roundToPx() }
|
||||
}
|
||||
|
||||
val (thumbnailSizeDp, thumbnailSizePx) = remember {
|
||||
val size = minOf(configuration.screenHeightDp, configuration.screenWidthDp).dp
|
||||
size to density.run { size.minus(64.dp).roundToPx() }
|
||||
}
|
||||
|
||||
val song by remember(player.mediaItem?.mediaId) {
|
||||
player.mediaItem?.mediaId?.let(Database::songFlow)?.distinctUntilChanged() ?: flowOf(null)
|
||||
}.collectAsState(initial = null, context = Dispatchers.IO)
|
||||
|
||||
|
||||
BottomSheet(
|
||||
state = layoutState,
|
||||
modifier = modifier,
|
||||
collapsedContent = {
|
||||
if (!layoutState.isExpanded) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.height(layoutState.lowerBound)
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
alpha = 1f - (layoutState.progress * 16).coerceAtMost(1f)
|
||||
}
|
||||
.drawWithCache {
|
||||
val offset = 64.dp.toPx()
|
||||
val x = ((size.width - offset) * player.progress) + offset
|
||||
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawLine(
|
||||
color = colorPalette.text,
|
||||
start = Offset(
|
||||
x = offset,
|
||||
y = 1.dp.toPx()
|
||||
),
|
||||
end = Offset(
|
||||
x = x,
|
||||
y = 1.dp.toPx()
|
||||
),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
.background(colorPalette.elevatedBackground)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = "${player.mediaMetadata.artworkUri}-w$smallThumbnailSize-h$smallThumbnailSize",
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
BasicText(
|
||||
text = player.mediaMetadata.title?.toString() ?: "",
|
||||
style = typography.xs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
BasicText(
|
||||
text = player.mediaMetadata.artist?.toString() ?: "",
|
||||
style = typography.xs,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
when {
|
||||
player.playbackState == Player.STATE_ENDED || !player.playWhenReady -> Image(
|
||||
painter = painterResource(R.drawable.play),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
if (player.playbackState == Player.STATE_IDLE) {
|
||||
player.mediaController.prepare()
|
||||
}
|
||||
player.mediaController.play()
|
||||
}
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
else -> Image(
|
||||
painter = painterResource(R.drawable.pause),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
player.mediaController.pause()
|
||||
}
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background)
|
||||
.padding(bottom = 72.dp)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
var scrubbingPosition by remember {
|
||||
mutableStateOf<Long?>(null)
|
||||
}
|
||||
|
||||
TopAppBar {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ellipsis_horizontal),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
menuState.display {
|
||||
QueuedMediaItemMenu(
|
||||
mediaItem = player.mediaItem ?: MediaItem.EMPTY,
|
||||
indexInQueue = player.mediaItemIndex,
|
||||
onDismiss = menuState::hide,
|
||||
onGlobalRouteEmitted = layoutState.collapse
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (player.error == null) {
|
||||
AnimatedContent(
|
||||
targetState = player.mediaItemIndex,
|
||||
transitionSpec = {
|
||||
val slideDirection =
|
||||
if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
|
||||
|
||||
(slideIntoContainer(slideDirection) + fadeIn() with
|
||||
slideOutOfContainer(slideDirection) + fadeOut()).using(
|
||||
SizeTransform(clip = false)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
val artworkUri = remember(it) {
|
||||
player.mediaController.getMediaItemAt(it).mediaMetadata.artworkUri
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = "$artworkUri-w$thumbnailSizePx-h$thumbnailSizePx",
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 32.dp)
|
||||
.padding(horizontal = 32.dp)
|
||||
.size(thumbnailSizeDp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(bottom = 32.dp)
|
||||
.padding(horizontal = 32.dp)
|
||||
.size(thumbnailSizeDp)
|
||||
) {
|
||||
// BasicText(
|
||||
// text = playerState.error?.message ?: "",
|
||||
// style = typography.xs.medium
|
||||
// )
|
||||
Error(
|
||||
error = Outcome.Error.Unhandled(player.error!!),
|
||||
onRetry = {
|
||||
player.mediaController.playWhenReady = true
|
||||
player.mediaController.prepare()
|
||||
player.error = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BasicText(
|
||||
text = player.mediaMetadata.title?.toString() ?: "",
|
||||
style = typography.l.bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp)
|
||||
)
|
||||
|
||||
|
||||
BasicText(
|
||||
text = player.mediaMetadata.extras?.getStringArrayList("artistNames")
|
||||
?.joinToString("") ?: "",
|
||||
style = typography.s.semiBold.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp)
|
||||
)
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
DefaultTimeBar(context).also {
|
||||
it.setPlayedColor(colorPalette.text.toArgb())
|
||||
it.setUnplayedColor(colorPalette.textDisabled.toArgb())
|
||||
it.setScrubberColor(colorPalette.text.toArgb())
|
||||
it.addListener(object : TimeBar.OnScrubListener {
|
||||
override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit
|
||||
|
||||
override fun onScrubMove(timeBar: TimeBar, position: Long) {
|
||||
scrubbingPosition = position
|
||||
}
|
||||
|
||||
override fun onScrubStop(
|
||||
timeBar: TimeBar,
|
||||
position: Long,
|
||||
canceled: Boolean
|
||||
) {
|
||||
if (!canceled) {
|
||||
scrubbingPosition = position
|
||||
player.mediaController.seekTo(position)
|
||||
player.currentPosition = player.mediaController.currentPosition
|
||||
}
|
||||
scrubbingPosition = null
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
update = {
|
||||
it.setDuration(player.duration)
|
||||
it.setPosition(player.currentPosition)
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp)
|
||||
.padding(horizontal = 32.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
val text by remember {
|
||||
derivedStateOf {
|
||||
DateUtils.formatElapsedTime((scrubbingPosition ?: player.currentPosition) / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
BasicText(
|
||||
text = text,
|
||||
style = typography.xxs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
if (player.duration != C.TIME_UNSET) {
|
||||
BasicText(
|
||||
text = DateUtils.formatElapsedTime(player.duration / 1000),
|
||||
style = typography.xxs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 32.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.heart),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(
|
||||
song?.likedAt?.let { colorPalette.red } ?: colorPalette.textDisabled
|
||||
),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.update(
|
||||
(song ?: Database.insert(player.mediaItem!!)).toggleLike()
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(28.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.play_skip_back),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
player.mediaController.seekToPrevious()
|
||||
}
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(32.dp)
|
||||
)
|
||||
|
||||
when {
|
||||
player.playbackState == Player.STATE_ENDED || !player.playWhenReady -> Image(
|
||||
painter = painterResource(R.drawable.play_circle),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
if (player.playbackState == Player.STATE_IDLE) {
|
||||
player.mediaController.prepare()
|
||||
}
|
||||
|
||||
player.mediaController.play()
|
||||
}
|
||||
.size(64.dp)
|
||||
)
|
||||
else -> Image(
|
||||
painter = painterResource(R.drawable.pause_circle),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
player.mediaController.pause()
|
||||
}
|
||||
.size(64.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.play_skip_forward),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
player.mediaController.seekToNext()
|
||||
}
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(32.dp)
|
||||
)
|
||||
|
||||
|
||||
Image(
|
||||
painter = painterResource(
|
||||
if (player.repeatMode == Player.REPEAT_MODE_ONE) {
|
||||
R.drawable.repeat_one
|
||||
} else {
|
||||
R.drawable.repeat
|
||||
}
|
||||
),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(
|
||||
if (player.repeatMode == Player.REPEAT_MODE_OFF) {
|
||||
colorPalette.textDisabled
|
||||
} else {
|
||||
colorPalette.text
|
||||
}
|
||||
),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
player.mediaController.repeatMode =
|
||||
(player.mediaController.repeatMode + 2) % 3
|
||||
preferences.repeatMode = player.mediaController.repeatMode
|
||||
}
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(28.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PlayerBottomSheet(
|
||||
layoutState = rememberBottomSheetState(64.dp, layoutState.upperBound - 128.dp),
|
||||
onGlobalRouteEmitted = layoutState.collapse,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 128.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package it.vfsfitvnm.vimusic.ui.views
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.models.PlaylistPreview
|
||||
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
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun PlaylistPreviewItem(
|
||||
playlistPreview: PlaylistPreview,
|
||||
modifier: Modifier = Modifier,
|
||||
thumbnailSize: Dp = 54.dp,
|
||||
) {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
val thumbnailSizePx = density.run {
|
||||
thumbnailSize.toPx().toInt()
|
||||
}
|
||||
|
||||
val thumbnails by remember(playlistPreview.playlist.id) {
|
||||
Database.playlistThumbnailUrls(playlistPreview.playlist.id).distinctUntilChanged()
|
||||
}.collectAsState(initial = emptyList(), context = Dispatchers.IO)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(colorPalette.lightBackground)
|
||||
.size(thumbnailSize * 2)
|
||||
) {
|
||||
if (thumbnails.toSet().size == 1) {
|
||||
AsyncImage(
|
||||
model = "${thumbnails.first()}-w${thumbnailSizePx * 2}-h${thumbnailSizePx * 2}",
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(thumbnailSize * 2)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Alignment.TopStart,
|
||||
Alignment.TopEnd,
|
||||
Alignment.BottomStart,
|
||||
Alignment.BottomEnd
|
||||
).forEachIndexed { index, alignment ->
|
||||
AsyncImage(
|
||||
model = "${thumbnails.getOrNull(index)}-w$thumbnailSizePx-h$thumbnailSizePx",
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.align(alignment)
|
||||
.size(thumbnailSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BasicText(
|
||||
text = playlistPreview.playlist.name,
|
||||
style = typography.xxs.semiBold.color(Color.White),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomStart)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.75f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
199
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt
Normal file
199
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt
Normal file
@@ -0,0 +1,199 @@
|
||||
package it.vfsfitvnm.vimusic.ui.views
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
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.text.BasicText
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
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.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.MediaItem
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.SongWithInfo
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun SongItem(
|
||||
mediaItem: MediaItem,
|
||||
thumbnailSize: Int,
|
||||
onClick: () -> Unit,
|
||||
menuContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color? = null,
|
||||
onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
SongItem(
|
||||
thumbnailModel = ImageRequest.Builder(LocalContext.current)
|
||||
.diskCacheKey(mediaItem.mediaId)
|
||||
.data("${mediaItem.mediaMetadata.artworkUri}-w$thumbnailSize-h$thumbnailSize")
|
||||
.build(),
|
||||
title = mediaItem.mediaMetadata.title!!.toString(),
|
||||
authors = mediaItem.mediaMetadata.artist.toString(),
|
||||
durationText = mediaItem.mediaMetadata.extras?.getString("durationText") ?: "?",
|
||||
menuContent = menuContent,
|
||||
onClick = onClick,
|
||||
onThumbnailContent = onThumbnailContent,
|
||||
backgroundColor = backgroundColor,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun SongItem(
|
||||
song: SongWithInfo,
|
||||
thumbnailSize: Int,
|
||||
onClick: () -> Unit,
|
||||
menuContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color? = null,
|
||||
onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
SongItem(
|
||||
thumbnailModel = "${song.song.thumbnailUrl}-w$thumbnailSize-h$thumbnailSize",
|
||||
title = song.song.title,
|
||||
authors = song.authors?.joinToString("") { it.text } ?: "",
|
||||
durationText = song.song.durationText,
|
||||
menuContent = menuContent,
|
||||
onClick = onClick,
|
||||
onThumbnailContent = onThumbnailContent,
|
||||
backgroundColor = backgroundColor,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun SongItem(
|
||||
thumbnailModel: Any?,
|
||||
title: String,
|
||||
authors: String,
|
||||
durationText: String,
|
||||
onClick: () -> Unit,
|
||||
menuContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color? = null,
|
||||
onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
SongItem(
|
||||
title = title,
|
||||
authors = authors,
|
||||
durationText = durationText,
|
||||
onClick = onClick,
|
||||
startContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(54.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = thumbnailModel,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
)
|
||||
|
||||
onThumbnailContent?.invoke(this)
|
||||
}
|
||||
},
|
||||
menuContent = menuContent,
|
||||
backgroundColor = backgroundColor,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun SongItem(
|
||||
title: String,
|
||||
authors: String,
|
||||
durationText: String?,
|
||||
onClick: () -> Unit,
|
||||
startContent: @Composable () -> Unit,
|
||||
menuContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color? = null,
|
||||
) {
|
||||
val menuState = LocalMenuState.current
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = onClick
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.background(backgroundColor ?: colorPalette.background)
|
||||
.padding(start = 16.dp, end = 8.dp)
|
||||
) {
|
||||
startContent()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
BasicText(
|
||||
text = title,
|
||||
style = typography.xs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
BasicText(
|
||||
text = buildString {
|
||||
append(authors)
|
||||
if (authors.isNotEmpty() && durationText != null) {
|
||||
append(" • ")
|
||||
}
|
||||
append(durationText)
|
||||
},
|
||||
style = typography.xs.semiBold.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ellipsis_vertical),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.textSecondary),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
menuState.display(menuContent)
|
||||
}
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user