feat(player): 3D-переворот обложки при смене трека

Вместо простой смены — эффект переворота (как страница альбома/пластинка):
старая обложка улетает передней гранью (0–90°), новая прилетает задней
(90–180°, контр-вращение чтобы не зеркалилась). Компонент FlipCover,
подключён к обложке в плеере; срабатывает при смене coverUrl трека.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 17:19:10 +03:00
parent 147b3ac81d
commit a46e437351
2 changed files with 83 additions and 8 deletions

View 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-переворота при смене изображения — как будто
* перелистывается страница альбома / пластинка. Старая обложка «улетает»
* передней стороной (090°), новая «прилетает» задней (90180°).
*/
@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()
}
}
}
}

View File

@@ -140,14 +140,11 @@ fun PlayerBottomSheet(
contentAlignment = Alignment.Center
) {
val coverModel = track?.coverUrl ?: station?.coverUrl
if (!coverModel.isNullOrBlank()) {
AsyncImage(
model = com.radiola.ui.components.crossfadeModel(coverModel),
com.radiola.ui.components.FlipCover(
model = coverModel,
contentDescription = station?.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
modifier = Modifier.fillMaxSize()
) {
Icon(
imageVector = Lucide.Radio,
contentDescription = null,