feat(player): 3D-переворот обложки при смене трека
Вместо простой смены — эффект переворота (как страница альбома/пластинка): старая обложка улетает передней гранью (0–90°), новая прилетает задней (90–180°, контр-вращение чтобы не зеркалилась). Компонент FlipCover, подключён к обложке в плеере; срабатывает при смене coverUrl трека. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
78
app/src/main/java/com/radiola/ui/components/FlipCover.kt
Normal file
78
app/src/main/java/com/radiola/ui/components/FlipCover.kt
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package com.radiola.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обложка с эффектом 3D-переворота при смене изображения — как будто
|
||||||
|
* перелистывается страница альбома / пластинка. Старая обложка «улетает»
|
||||||
|
* передней стороной (0–90°), новая «прилетает» задней (90–180°).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FlipCover(
|
||||||
|
model: String?,
|
||||||
|
contentDescription: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
fallback: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
var current by remember { mutableStateOf(model) }
|
||||||
|
var previous by remember { mutableStateOf(model) }
|
||||||
|
val rotation = remember { Animatable(0f) }
|
||||||
|
|
||||||
|
LaunchedEffect(model) {
|
||||||
|
if (model != current) {
|
||||||
|
previous = current
|
||||||
|
current = model
|
||||||
|
rotation.snapTo(0f)
|
||||||
|
rotation.animateTo(180f, animationSpec = tween(620, easing = FastOutSlowInEasing))
|
||||||
|
// Оседаем: новая обложка становится «лицом», угол 0 — без рывка.
|
||||||
|
previous = current
|
||||||
|
rotation.snapTo(0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val angle = rotation.value
|
||||||
|
val showFront = angle <= 90f
|
||||||
|
val faceModel = if (showFront) previous else current
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.graphicsLayer {
|
||||||
|
rotationY = angle
|
||||||
|
cameraDistance = 16f * density
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
// Заднюю грань контр-вращаем, чтобы изображение не было зеркальным.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer { rotationY = if (showFront) 0f else 180f },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (!faceModel.isNullOrBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = faceModel,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
fallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,14 +140,11 @@ fun PlayerBottomSheet(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
val coverModel = track?.coverUrl ?: station?.coverUrl
|
val coverModel = track?.coverUrl ?: station?.coverUrl
|
||||||
if (!coverModel.isNullOrBlank()) {
|
com.radiola.ui.components.FlipCover(
|
||||||
AsyncImage(
|
model = coverModel,
|
||||||
model = com.radiola.ui.components.crossfadeModel(coverModel),
|
contentDescription = station?.name,
|
||||||
contentDescription = station?.name,
|
modifier = Modifier.fillMaxSize()
|
||||||
modifier = Modifier.fillMaxSize(),
|
) {
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Lucide.Radio,
|
imageVector = Lucide.Radio,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
|
|||||||
Reference in New Issue
Block a user