Created
June 7, 2026 10:04
-
-
Save Kyriakos-Georgiopoulos/511813e2998d177517ed817630f518b0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @file:OptIn(ExperimentalSharedTransitionApi::class, ExperimentalAnimationApi::class) | |
| import android.graphics.BlurMaskFilter | |
| import androidx.compose.animation.AnimatedVisibilityScope | |
| import androidx.compose.animation.EnterExitState | |
| import androidx.compose.animation.ExperimentalAnimationApi | |
| import androidx.compose.animation.ExperimentalSharedTransitionApi | |
| import androidx.compose.animation.SharedTransitionLayout | |
| import androidx.compose.animation.SharedTransitionScope | |
| import androidx.compose.animation.animateColorAsState | |
| import androidx.compose.animation.core.Animatable | |
| import androidx.compose.animation.core.FastOutSlowInEasing | |
| import androidx.compose.animation.core.LinearEasing | |
| import androidx.compose.animation.core.LinearOutSlowInEasing | |
| import androidx.compose.animation.core.Spring | |
| import androidx.compose.animation.core.animateDpAsState | |
| import androidx.compose.animation.core.animateFloat | |
| import androidx.compose.animation.core.animateFloatAsState | |
| import androidx.compose.animation.core.spring | |
| import androidx.compose.animation.core.tween | |
| import androidx.compose.foundation.Canvas | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.border | |
| import androidx.compose.foundation.clickable | |
| import androidx.compose.foundation.gestures.detectDragGestures | |
| import androidx.compose.foundation.gestures.detectTapGestures | |
| import androidx.compose.foundation.interaction.MutableInteractionSource | |
| import androidx.compose.foundation.layout.Arrangement | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.Column | |
| import androidx.compose.foundation.layout.PaddingValues | |
| import androidx.compose.foundation.layout.Row | |
| import androidx.compose.foundation.layout.Spacer | |
| import androidx.compose.foundation.layout.WindowInsets | |
| import androidx.compose.foundation.layout.asPaddingValues | |
| import androidx.compose.foundation.layout.aspectRatio | |
| import androidx.compose.foundation.layout.fillMaxHeight | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.foundation.layout.fillMaxWidth | |
| import androidx.compose.foundation.layout.height | |
| import androidx.compose.foundation.layout.offset | |
| import androidx.compose.foundation.layout.padding | |
| import androidx.compose.foundation.layout.size | |
| import androidx.compose.foundation.layout.systemBars | |
| import androidx.compose.foundation.layout.width | |
| import androidx.compose.foundation.lazy.grid.GridCells | |
| import androidx.compose.foundation.lazy.grid.GridItemSpan | |
| import androidx.compose.foundation.lazy.grid.LazyVerticalGrid | |
| import androidx.compose.foundation.lazy.grid.items | |
| import androidx.compose.foundation.rememberScrollState | |
| import androidx.compose.foundation.shape.CircleShape | |
| import androidx.compose.foundation.shape.RoundedCornerShape | |
| import androidx.compose.foundation.verticalScroll | |
| import androidx.compose.material.icons.Icons | |
| import androidx.compose.material.icons.filled.ArrowBack | |
| import androidx.compose.material.icons.filled.FastForward | |
| import androidx.compose.material.icons.filled.FastRewind | |
| import androidx.compose.material.icons.filled.Pause | |
| import androidx.compose.material.icons.filled.PlayArrow | |
| import androidx.compose.material3.Icon | |
| import androidx.compose.material3.IconButton | |
| import androidx.compose.material3.MaterialTheme | |
| import androidx.compose.material3.Scaffold | |
| import androidx.compose.material3.Text | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.Immutable | |
| import androidx.compose.runtime.LaunchedEffect | |
| import androidx.compose.runtime.derivedStateOf | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableFloatStateOf | |
| import androidx.compose.runtime.mutableStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.rememberCoroutineScope | |
| import androidx.compose.runtime.setValue | |
| import androidx.compose.runtime.withFrameNanos | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.draw.clip | |
| import androidx.compose.ui.draw.clipToBounds | |
| import androidx.compose.ui.draw.drawWithCache | |
| import androidx.compose.ui.draw.rotate | |
| import androidx.compose.ui.draw.shadow | |
| import androidx.compose.ui.geometry.CornerRadius | |
| import androidx.compose.ui.geometry.Offset | |
| import androidx.compose.ui.geometry.Rect | |
| import androidx.compose.ui.geometry.RoundRect | |
| import androidx.compose.ui.geometry.Size | |
| import androidx.compose.ui.graphics.BlendMode | |
| import androidx.compose.ui.graphics.Brush | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.graphics.Path | |
| import androidx.compose.ui.graphics.PathEffect | |
| import androidx.compose.ui.graphics.Shadow | |
| import androidx.compose.ui.graphics.StrokeCap | |
| import androidx.compose.ui.graphics.drawscope.DrawScope | |
| import androidx.compose.ui.graphics.drawscope.Fill | |
| import androidx.compose.ui.graphics.drawscope.Stroke | |
| import androidx.compose.ui.graphics.drawscope.clipPath | |
| import androidx.compose.ui.graphics.drawscope.drawIntoCanvas | |
| import androidx.compose.ui.graphics.drawscope.rotate | |
| import androidx.compose.ui.graphics.drawscope.scale | |
| import androidx.compose.ui.graphics.graphicsLayer | |
| import androidx.compose.ui.graphics.luminance | |
| import androidx.compose.ui.graphics.nativeCanvas | |
| import androidx.compose.ui.graphics.vector.ImageVector | |
| import androidx.compose.ui.hapticfeedback.HapticFeedbackType | |
| import androidx.compose.ui.input.pointer.pointerInput | |
| import androidx.compose.ui.platform.LocalHapticFeedback | |
| import androidx.compose.ui.text.TextStyle | |
| import androidx.compose.ui.text.font.FontWeight | |
| import androidx.compose.ui.tooling.preview.Preview | |
| import androidx.compose.ui.unit.Dp | |
| import androidx.compose.ui.unit.dp | |
| import androidx.compose.ui.unit.sp | |
| import androidx.navigation.NavType | |
| import androidx.navigation.compose.NavHost | |
| import androidx.navigation.compose.composable | |
| import androidx.navigation.compose.rememberNavController | |
| import androidx.navigation.navArgument | |
| import kotlinx.coroutines.delay | |
| import kotlinx.coroutines.launch | |
| import kotlin.math.PI | |
| import kotlin.math.abs | |
| import kotlin.math.atan2 | |
| import kotlin.math.cos | |
| import kotlin.math.sin | |
| // ============================================================================ | |
| // DATA MODEL & MOCK DATA | |
| // ============================================================================ | |
| /** A vinyl record with its sleeve and label appearance. [discColor] controls the vinyl variant (clear, gold, colored, or classic black). */ | |
| @Immutable | |
| data class VinylAlbum( | |
| val id: Int, | |
| val title: String, | |
| val artist: String, | |
| val sleeveColor: Color, | |
| val labelColorTop: Color, | |
| val labelColorBottom: Color, | |
| val discColor: Color = Color(0xFF111111), | |
| val tracks: List<String> | |
| ) | |
| val libraryData = listOf( | |
| VinylAlbum( | |
| id = 1, | |
| title = "Random Access Memories", | |
| artist = "Daft Punk", | |
| sleeveColor = Color(0xFF1E1E1E), | |
| labelColorTop = Color(0xFFC0C0C0), | |
| labelColorBottom = Color(0xFFDAA520), | |
| discColor = Color(0xFF111111), | |
| tracks = listOf( | |
| "Give Life Back to Music", | |
| "The Game of Love", | |
| "Giorgio by Moroder", | |
| "Within", | |
| "Instant Crush" | |
| ) | |
| ), | |
| VinylAlbum( | |
| id = 2, | |
| title = "The Dark Side of the Moon", | |
| artist = "Pink Floyd", | |
| sleeveColor = Color(0xFF0F0F0F), | |
| labelColorTop = Color(0xFF000000), | |
| labelColorBottom = Color(0xFF333333), | |
| discColor = Color(0xFF111111), | |
| tracks = listOf( | |
| "Speak to Me", | |
| "Breathe (In the Air)", | |
| "On the Run", | |
| "Time", | |
| "The Great Gig in the Sky" | |
| ) | |
| ), | |
| VinylAlbum( | |
| id = 3, | |
| title = "Kind of Blue", | |
| artist = "Miles Davis", | |
| sleeveColor = Color(0xFF2B3A42), | |
| labelColorTop = Color(0xFF0000CD), | |
| labelColorBottom = Color(0xFF00008B), | |
| discColor = Color(0xFF111111), | |
| tracks = listOf("So What", "Freddie Freeloader", "Blue in Green", "All Blues", "Flamenco Sketches") | |
| ), | |
| VinylAlbum( | |
| id = 4, | |
| title = "Abbey Road", | |
| artist = "The Beatles", | |
| sleeveColor = Color(0xFFE5E2D9), | |
| labelColorTop = Color(0xFF228B22), | |
| labelColorBottom = Color(0xFF006400), | |
| discColor = Color(0xFF111111), | |
| tracks = listOf( | |
| "Come Together", | |
| "Something", | |
| "Maxwell's Silver Hammer", | |
| "Oh! Darling", | |
| "Octopus's Garden" | |
| ) | |
| ), | |
| VinylAlbum( | |
| id = 5, | |
| title = "Rumours", | |
| artist = "Fleetwood Mac", | |
| sleeveColor = Color(0xFFD6CFC4), | |
| labelColorTop = Color(0xFFE8E5D5), | |
| labelColorBottom = Color(0xFFC2C5B5), | |
| discColor = Color(0x33EEEEEE), | |
| tracks = listOf( | |
| "Second Hand News", | |
| "Dreams", | |
| "Never Going Back Again", | |
| "Don't Stop", | |
| "Go Your Own Way" | |
| ) | |
| ), | |
| VinylAlbum( | |
| id = 6, | |
| title = "Currents", | |
| artist = "Tame Impala", | |
| sleeveColor = Color(0xFF2A1B38), | |
| labelColorTop = Color(0xFF2A2A2A), | |
| labelColorBottom = Color(0xFF0A0A0A), | |
| discColor = Color(0xFFFF5500), | |
| tracks = listOf("Let It Happen", "Nangs", "The Moment", "Yes I'm Changing", "Eventually") | |
| ), | |
| VinylAlbum( | |
| id = 7, | |
| title = "Thriller", | |
| artist = "Michael Jackson", | |
| sleeveColor = Color(0xFFEAEAEA), | |
| labelColorTop = Color(0xFF00B4DB), | |
| labelColorBottom = Color(0xFF0083B0), | |
| discColor = Color(0xFF111111), | |
| tracks = listOf( | |
| "Wanna Be Startin' Somethin'", | |
| "Baby Be Mine", | |
| "The Girl Is Mine", | |
| "Thriller", | |
| "Beat It" | |
| ) | |
| ), | |
| VinylAlbum( | |
| id = 8, | |
| title = "Purple Rain", | |
| artist = "Prince", | |
| sleeveColor = Color(0xFF3B205E), | |
| labelColorTop = Color(0xFFFDE8CD), | |
| labelColorBottom = Color(0xFFD4AF37), | |
| discColor = Color(0xFF6A0DAD), | |
| tracks = listOf( | |
| "Let's Go Crazy", | |
| "Take Me With U", | |
| "The Beautiful Ones", | |
| "Computer Blue", | |
| "Darling Nikki" | |
| ) | |
| ), | |
| VinylAlbum( | |
| id = 9, | |
| title = "Back to Black", | |
| artist = "Amy Winehouse", | |
| sleeveColor = Color(0xFF111111), | |
| labelColorTop = Color(0xFFFF416C), | |
| labelColorBottom = Color(0xFFFF4B2B), | |
| discColor = Color(0xFF111111), | |
| tracks = listOf("Rehab", "You Know I'm No Good", "Me & Mr Jones", "Just Friends", "Back to Black") | |
| ), | |
| VinylAlbum( | |
| id = 10, | |
| title = "A Love Supreme", | |
| artist = "John Coltrane", | |
| sleeveColor = Color(0xFFF0EAD6), | |
| labelColorTop = Color(0xFFFF6A00), | |
| labelColorBottom = Color(0xFFD32F2F), | |
| discColor = Color(0xFFD4AF37), | |
| tracks = listOf( | |
| "Part I - Acknowledgement", | |
| "Part II - Resolution", | |
| "Part III - Pursuance", | |
| "Part IV - Psalm" | |
| ) | |
| ) | |
| ) | |
| /** Turntable physics and geometry constants. Tonearm angles are in degrees: rest → start (over first groove) → end (inner groove). */ | |
| object TurntableDesign { | |
| const val RPM_33 = 33.333f | |
| const val TonearmRestAngle = -16f | |
| const val TonearmStartAngle = -6f | |
| const val TonearmEndAngle = 18f | |
| const val TrackDurationMs = 180_000 | |
| } | |
| private val ActiveTrackTextStyle = TextStyle( | |
| shadow = Shadow(color = Color.White, offset = Offset(0f, 2f), blurRadius = 0f) | |
| ) | |
| private val InactiveTrackTextStyle = TextStyle() | |
| private val KnurlingColorStops = Array(41) { i -> | |
| i / 40f to if (i % 2 == 0) Color(0xFF1A1A1A) else Color(0xFF666666) | |
| } | |
| // Pre-allocated Paint objects — cached at top level to avoid heap allocations in per-frame draw calls. | |
| private val TonearmRestShadowPaint = android.graphics.Paint().apply { | |
| color = android.graphics.Color.argb(140, 0, 0, 0) | |
| maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.NORMAL) | |
| } | |
| private val TonearmBaseShadowPaint = android.graphics.Paint().apply { | |
| color = android.graphics.Color.argb(150, 0, 0, 0) | |
| maskFilter = BlurMaskFilter(12f, BlurMaskFilter.Blur.NORMAL) | |
| } | |
| private val ControlsTowerShadowPaint = android.graphics.Paint().apply { | |
| color = android.graphics.Color.argb(150, 0, 0, 0) | |
| maskFilter = BlurMaskFilter(10f, BlurMaskFilter.Blur.NORMAL) | |
| } | |
| private val SleeveShadowPaint = android.graphics.Paint().apply { | |
| color = android.graphics.Color.argb(120, 0, 0, 0) | |
| maskFilter = BlurMaskFilter(16f, BlurMaskFilter.Blur.NORMAL) | |
| } | |
| private val PlatterShadowPaint = android.graphics.Paint().apply { | |
| color = android.graphics.Color.argb(160, 0, 0, 0) | |
| maskFilter = BlurMaskFilter(24f, BlurMaskFilter.Blur.NORMAL) | |
| } | |
| private val FaderKnobShadowPaint = android.graphics.Paint().apply { | |
| color = android.graphics.Color.argb(160, 0, 0, 0) | |
| maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.NORMAL) | |
| } | |
| // ============================================================================ | |
| // MAIN NAVIGATION | |
| // ============================================================================ | |
| /** Root composable. Two-screen nav: album grid → turntable player with shared element transitions. */ | |
| @Preview(showBackground = true) | |
| @Composable | |
| fun VinylArtApp() { | |
| MaterialTheme { | |
| val navController = rememberNavController() | |
| SharedTransitionLayout { | |
| NavHost(navController = navController, startDestination = "grid") { | |
| composable("grid") { | |
| VinylGridScreen( | |
| albums = libraryData, | |
| animatedVisibilityScope = this@composable, | |
| onAlbumClick = { albumId -> navController.navigate("player/$albumId") } | |
| ) | |
| } | |
| composable( | |
| "player/{albumId}", | |
| arguments = listOf(navArgument("albumId") { type = NavType.IntType }) | |
| ) { backStackEntry -> | |
| val albumId = backStackEntry.arguments?.getInt("albumId") ?: 1 | |
| val album = libraryData.first { it.id == albumId } | |
| PlayerScreen( | |
| album = album, | |
| animatedVisibilityScope = this@composable, | |
| onBack = { navController.popBackStack() } | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // ============================================================================ | |
| // SCREEN 1: THE EDITORIAL GALLERY | |
| // ============================================================================ | |
| /** Gallery grid showing album sleeves with vinyl discs peeking out. Each item is a shared element transition source. */ | |
| @Composable | |
| fun SharedTransitionScope.VinylGridScreen( | |
| albums: List<VinylAlbum>, | |
| animatedVisibilityScope: AnimatedVisibilityScope, | |
| onAlbumClick: (Int) -> Unit | |
| ) { | |
| val systemBars = WindowInsets.systemBars.asPaddingValues() | |
| Scaffold( | |
| containerColor = Color(0xFFF5F5F7), | |
| contentWindowInsets = WindowInsets(0, 0, 0, 0) | |
| ) { _ -> | |
| LazyVerticalGrid( | |
| columns = GridCells.Fixed(2), | |
| contentPadding = PaddingValues( | |
| start = 16.dp, | |
| end = 16.dp, | |
| top = systemBars.calculateTopPadding() + 24.dp, | |
| bottom = systemBars.calculateBottomPadding() + 48.dp | |
| ), | |
| horizontalArrangement = Arrangement.spacedBy(20.dp), | |
| verticalArrangement = Arrangement.spacedBy(36.dp), | |
| modifier = Modifier.fillMaxSize() | |
| ) { | |
| item(span = { GridItemSpan(maxLineSpan) }) { | |
| MuseumHeader() | |
| } | |
| items(albums, key = { it.id }) { album -> | |
| VinylGridItem( | |
| album = album, | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| onAlbumClick = onAlbumClick | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| fun MuseumHeader() { | |
| Column( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(horizontal = 8.dp, vertical = 12.dp) | |
| ) { | |
| Text( | |
| text = "HIGH FIDELITY", | |
| color = Color.Black.copy(alpha = 0.4f), | |
| fontSize = 10.sp, | |
| fontWeight = FontWeight.Bold, | |
| letterSpacing = 4.sp | |
| ) | |
| Spacer(modifier = Modifier.height(4.dp)) | |
| Text( | |
| text = "MASTER COLLECTION", | |
| style = TextStyle( | |
| color = Color(0xFF1A1A1A), | |
| shadow = Shadow(color = Color.White, offset = Offset(0f, 3f), blurRadius = 0f) | |
| ), | |
| fontSize = 28.sp, | |
| fontWeight = FontWeight.Black, | |
| letterSpacing = (-0.5).sp | |
| ) | |
| } | |
| } | |
| @Composable | |
| fun SharedTransitionScope.VinylGridItem( | |
| album: VinylAlbum, | |
| animatedVisibilityScope: AnimatedVisibilityScope, | |
| onAlbumClick: (Int) -> Unit | |
| ) { | |
| Column( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .clickable( | |
| interactionSource = remember { MutableInteractionSource() }, | |
| indication = null, | |
| onClick = { onAlbumClick(album.id) } | |
| ) | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .aspectRatio(1.15f), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxHeight() | |
| .fillMaxWidth(0.75f) | |
| .align(Alignment.CenterStart) | |
| .offset(x = 4.dp) | |
| .rotate(-2f) | |
| ) { | |
| Canvas(modifier = Modifier.fillMaxSize()) { | |
| val corner = 4.dp.toPx() | |
| drawIntoCanvas { canvas -> | |
| val paint = SleeveShadowPaint | |
| canvas.nativeCanvas.drawRoundRect( | |
| 4.dp.toPx(), | |
| 8.dp.toPx(), | |
| size.width, | |
| size.height + 4.dp.toPx(), | |
| corner, | |
| corner, | |
| paint | |
| ) | |
| } | |
| val isLightSleeve = album.sleeveColor.luminance() > 0.5f | |
| val sleeveBrush = Brush.linearGradient( | |
| listOf( | |
| album.sleeveColor, | |
| album.sleeveColor.copy(alpha = 0.6f) | |
| ) | |
| ) | |
| drawRoundRect( | |
| brush = sleeveBrush, | |
| size = size, | |
| cornerRadius = CornerRadius(corner) | |
| ) | |
| drawLine( | |
| color = Color.White.copy(alpha = if (isLightSleeve) 0.5f else 0.1f), | |
| start = Offset(0f, corner), | |
| end = Offset(0f, size.height - corner), | |
| strokeWidth = 2.dp.toPx() | |
| ) | |
| val ringR = size.width * 0.44f | |
| drawIntoCanvas { canvas -> | |
| val darkScuffPaint = android.graphics.Paint().apply { | |
| color = android.graphics.Color.argb( | |
| if (isLightSleeve) 15 else 40, | |
| 0, | |
| 0, | |
| 0 | |
| ); style = android.graphics.Paint.Style.STROKE; strokeWidth = | |
| 6.dp.toPx(); maskFilter = | |
| BlurMaskFilter(6.dp.toPx(), BlurMaskFilter.Blur.NORMAL) | |
| } | |
| canvas.nativeCanvas.drawCircle( | |
| size.width / 2, | |
| size.height / 2, | |
| ringR, | |
| darkScuffPaint | |
| ) | |
| val lightScuffPaint = android.graphics.Paint().apply { | |
| color = android.graphics.Color.argb( | |
| if (isLightSleeve) 40 else 15, | |
| 255, | |
| 255, | |
| 255 | |
| ); style = android.graphics.Paint.Style.STROKE; strokeWidth = | |
| 3.dp.toPx(); maskFilter = | |
| BlurMaskFilter(3.dp.toPx(), BlurMaskFilter.Blur.NORMAL) | |
| } | |
| canvas.nativeCanvas.drawCircle( | |
| size.width / 2, | |
| size.height / 2, | |
| ringR - 2.dp.toPx(), | |
| lightScuffPaint | |
| ) | |
| } | |
| } | |
| } | |
| Canvas( | |
| modifier = Modifier | |
| .fillMaxHeight(0.9f) | |
| .aspectRatio(1f) | |
| .align(Alignment.CenterEnd) | |
| ) { | |
| val radius = size.minDimension / 2f | |
| val shadowCenter = | |
| Offset((size.width / 2f) - 6.dp.toPx(), (size.height / 2f) + 6.dp.toPx()) | |
| val shadowAlpha = if (album.discColor.alpha < 1f) 0.3f else 0.5f | |
| drawCircle( | |
| brush = Brush.radialGradient( | |
| listOf( | |
| Color.Black.copy(alpha = shadowAlpha), | |
| Color.Transparent | |
| ), center = shadowCenter, radius = radius * 1.15f | |
| ), radius = radius * 1.15f, center = shadowCenter | |
| ) | |
| } | |
| Box( | |
| modifier = Modifier | |
| .fillMaxHeight(0.9f) | |
| .aspectRatio(1f) | |
| .align(Alignment.CenterEnd) | |
| .sharedElement( | |
| rememberSharedContentState("vinyl_${album.id}"), | |
| animatedVisibilityScope, | |
| boundsTransform = { _, _ -> spring(dampingRatio = 0.85f, stiffness = 100f) } | |
| ) | |
| ) { | |
| VinylRecord( | |
| modifier = Modifier.fillMaxSize(), | |
| surfaceRotationDegrees = { 0f }, | |
| platterCenterOffset = Offset(0.5f, 0.5f), | |
| album = album | |
| ) | |
| } | |
| } | |
| Spacer(modifier = Modifier.height(16.dp)) | |
| Text( | |
| text = album.title, | |
| color = Color.Black, | |
| fontSize = 15.sp, | |
| fontWeight = FontWeight.ExtraBold, | |
| maxLines = 1, | |
| letterSpacing = 0.2.sp, | |
| modifier = Modifier.sharedElement( | |
| rememberSharedContentState("title_${album.id}"), | |
| animatedVisibilityScope | |
| ) | |
| ) | |
| Spacer(modifier = Modifier.height(2.dp)) | |
| Text( | |
| text = album.artist, | |
| color = Color(0xFFAAAAAA), | |
| fontSize = 13.sp, | |
| fontWeight = FontWeight.Medium, | |
| maxLines = 1, | |
| modifier = Modifier.sharedElement( | |
| rememberSharedContentState("artist_${album.id}"), | |
| animatedVisibilityScope | |
| ) | |
| ) | |
| } | |
| } | |
| // ============================================================================ | |
| // SCREEN 2: THE TURNTABLE PLAYER | |
| // ============================================================================ | |
| /** | |
| * Full turntable player screen. Manages playback state, tonearm animation, vinyl rotation, and fader control. | |
| * | |
| * Interactions: drag the tonearm to seek, drag the fader to pitch-bend, tap the start/stop button on the chassis. | |
| * The tonearm angle drives track selection via [derivedStateOf] to avoid recomposing the tracklist every frame. | |
| */ | |
| @Composable | |
| fun SharedTransitionScope.PlayerScreen( | |
| album: VinylAlbum, | |
| animatedVisibilityScope: AnimatedVisibilityScope, | |
| onBack: () -> Unit | |
| ) { | |
| val coroutineScope = rememberCoroutineScope() | |
| val hapticFeedback = LocalHapticFeedback.current | |
| var isPlaying by remember { mutableStateOf(false) } | |
| var isDraggingTonearm by remember { mutableStateOf(false) } | |
| var isDraggingFader by remember { mutableStateOf(false) } | |
| var faderPosition by remember { mutableFloatStateOf(0.5f) } | |
| var isStartButtonPressed by remember { mutableStateOf(false) } | |
| val startButtonTravel by animateFloatAsState( | |
| targetValue = if (isStartButtonPressed) 1f else 0f, | |
| animationSpec = spring( | |
| stiffness = Spring.StiffnessHigh, | |
| dampingRatio = Spring.DampingRatioNoBouncy | |
| ), | |
| label = "buttonTravel" | |
| ) | |
| val tonearmAngle = remember { Animatable(TurntableDesign.TonearmRestAngle) } | |
| val motorRpm = remember { Animatable(0f) } | |
| var vinylRotation by remember { mutableFloatStateOf(0f) } | |
| val activeTrackIndex by remember { | |
| derivedStateOf { | |
| val angle = tonearmAngle.value | |
| if (angle < TurntableDesign.TonearmStartAngle) -1 | |
| else { | |
| val progress = ((angle - TurntableDesign.TonearmStartAngle) / | |
| (TurntableDesign.TonearmEndAngle - TurntableDesign.TonearmStartAngle)).coerceIn( | |
| 0f, | |
| 0.999f | |
| ) | |
| ((progress * album.tracks.size).toInt()).coerceAtMost(album.tracks.size - 1) | |
| } | |
| } | |
| } | |
| val flipRotation by animatedVisibilityScope.transition.animateFloat( | |
| transitionSpec = { spring(dampingRatio = 0.85f, stiffness = 100f) }, | |
| label = "flipRotation" | |
| ) { state -> | |
| when (state) { | |
| EnterExitState.PreEnter -> -360f | |
| EnterExitState.Visible -> 0f | |
| EnterExitState.PostExit -> 360f | |
| } | |
| } | |
| LaunchedEffect(Unit) { | |
| delay(600) | |
| tonearmAngle.animateTo( | |
| TurntableDesign.TonearmRestAngle - 4f, | |
| tween(300, easing = LinearOutSlowInEasing) | |
| ) | |
| tonearmAngle.animateTo( | |
| TurntableDesign.TonearmStartAngle, | |
| tween(800, easing = FastOutSlowInEasing) | |
| ) | |
| coroutineScope.launch { | |
| tonearmAngle.animateTo( | |
| TurntableDesign.TonearmStartAngle, | |
| tween(300, easing = LinearOutSlowInEasing) | |
| ) | |
| } | |
| isPlaying = true | |
| } | |
| LaunchedEffect(isPlaying) { | |
| if (isPlaying && tonearmAngle.value >= TurntableDesign.TonearmStartAngle) { | |
| val progress = | |
| (tonearmAngle.value - TurntableDesign.TonearmStartAngle) / (TurntableDesign.TonearmEndAngle - TurntableDesign.TonearmStartAngle) | |
| val timeRemaining = | |
| (TurntableDesign.TrackDurationMs * (1f - progress.coerceIn(0f, 1f))).toLong() | |
| tonearmAngle.animateTo( | |
| targetValue = TurntableDesign.TonearmEndAngle, | |
| animationSpec = tween(durationMillis = timeRemaining.toInt(), easing = LinearEasing) | |
| ) | |
| if (tonearmAngle.value >= TurntableDesign.TonearmEndAngle - 0.1f) { | |
| isPlaying = false | |
| tonearmAngle.animateTo( | |
| TurntableDesign.TonearmRestAngle, | |
| tween(1000, easing = FastOutSlowInEasing) | |
| ) | |
| } | |
| } else if (!isPlaying) { | |
| tonearmAngle.stop() | |
| } | |
| } | |
| val skipTrack = { forward: Boolean -> | |
| if (tonearmAngle.value >= TurntableDesign.TonearmStartAngle) { | |
| coroutineScope.launch { | |
| val wasPlaying = isPlaying | |
| isPlaying = false | |
| val degreePerTrack = | |
| (TurntableDesign.TonearmEndAngle - TurntableDesign.TonearmStartAngle) / album.tracks.size | |
| val delta = if (forward) degreePerTrack else -degreePerTrack | |
| val targetAngle = (tonearmAngle.value + delta).coerceIn( | |
| TurntableDesign.TonearmStartAngle, | |
| TurntableDesign.TonearmEndAngle | |
| ) | |
| hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) | |
| tonearmAngle.animateTo(targetAngle, spring(dampingRatio = 0.8f, stiffness = 200f)) | |
| if (wasPlaying && targetAngle < TurntableDesign.TonearmEndAngle) { | |
| isPlaying = true | |
| } else if (targetAngle >= TurntableDesign.TonearmEndAngle) { | |
| tonearmAngle.animateTo( | |
| TurntableDesign.TonearmRestAngle, | |
| tween(1000, easing = FastOutSlowInEasing) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| LaunchedEffect(isPlaying) { | |
| motorRpm.animateTo( | |
| if (isPlaying) TurntableDesign.RPM_33 else 0f, | |
| tween(if (isPlaying) 800 else 1200, easing = LinearOutSlowInEasing) | |
| ) | |
| } | |
| LaunchedEffect(Unit) { | |
| var lastFrameTime = withFrameNanos { it } | |
| while (true) { | |
| val frameTime = withFrameNanos { it } | |
| val deltaMs = (frameTime - lastFrameTime) / 1_000_000f | |
| lastFrameTime = frameTime | |
| if (motorRpm.value > 0.05f) { | |
| val pitchMultiplier = 1f + ((faderPosition - 0.5f) * 0.16f) | |
| val degreesPerSec = (motorRpm.value * pitchMultiplier) * 6f | |
| vinylRotation = (vinylRotation + (degreesPerSec * (deltaMs / 1000f))) % 360f | |
| } | |
| } | |
| } | |
| Scaffold( | |
| containerColor = Color(0xFFF5F5F7), // Main gallery backdrop | |
| topBar = { | |
| PlayerHeader( | |
| album = album, | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| onBack = onBack | |
| ) | |
| }, | |
| bottomBar = { | |
| MinimalControlPanel( | |
| isPlaying = isPlaying, | |
| onPlayPause = { isPlaying = !isPlaying }, | |
| onSkipForward = { skipTrack(true) }, | |
| onSkipBackward = { skipTrack(false) } | |
| ) | |
| } | |
| ) { innerPadding -> | |
| Column( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(innerPadding), | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .weight(1f) | |
| .fillMaxWidth() | |
| .padding(horizontal = 24.dp) | |
| .padding(top = 12.dp) | |
| .clipToBounds(), // Prevents scrolling text from bleeding onto the turntable | |
| contentAlignment = Alignment.TopStart | |
| ) { | |
| EditorialTracklist(album = album, activeTrackIndex = activeTrackIndex) | |
| } | |
| TurntableChassis( | |
| modifier = Modifier | |
| .padding(bottom = 24.dp, start = 16.dp, end = 16.dp) | |
| .aspectRatio(0.85f) | |
| .fillMaxWidth() | |
| .shadow( | |
| 36.dp, | |
| RoundedCornerShape(8.dp), | |
| spotColor = Color(0x99000000), | |
| ambientColor = Color(0x66000000) | |
| ) | |
| ) { | |
| Box(modifier = Modifier.fillMaxSize()) { | |
| Canvas(modifier = Modifier.fillMaxSize()) { | |
| drawChassisDetails(size, faderPosition) | |
| val center = Offset(size.width * 0.46f, size.height * 0.46f) | |
| val platterRadius = size.minDimension * 0.44f | |
| drawIntoCanvas { canvas -> | |
| canvas.nativeCanvas.drawCircle( | |
| center.x + 6.dp.toPx(), | |
| center.y + 10.dp.toPx(), | |
| platterRadius + 1.dp.toPx(), | |
| PlatterShadowPaint | |
| ) | |
| } | |
| drawStrobePlatterBand(size, center, platterRadius) | |
| } | |
| VinylRecord( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .sharedElement( | |
| rememberSharedContentState(key = "vinyl_${album.id}"), | |
| animatedVisibilityScope = animatedVisibilityScope, | |
| boundsTransform = { _, _ -> | |
| spring( | |
| dampingRatio = 0.85f, | |
| stiffness = 100f | |
| ) | |
| } | |
| ) | |
| .graphicsLayer { | |
| rotationY = flipRotation | |
| cameraDistance = 12f * density | |
| }, | |
| surfaceRotationDegrees = { vinylRotation }, | |
| platterCenterOffset = Offset(0.46f, 0.46f), | |
| album = album | |
| ) | |
| Canvas( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .pointerInput(Unit) { | |
| detectTapGestures( | |
| onPress = { offset -> | |
| val btnWidth = size.width * 0.13f | |
| val btnHeight = size.height * 0.075f | |
| val btnTopLeft = | |
| Offset(size.width * 0.07f, size.height * 0.90f) | |
| if (offset.x in btnTopLeft.x..(btnTopLeft.x + btnWidth) && offset.y in btnTopLeft.y..(btnTopLeft.y + btnHeight)) { | |
| isStartButtonPressed = true | |
| if (tryAwaitRelease()) { | |
| isStartButtonPressed = false | |
| if (isPlaying) { | |
| isPlaying = false | |
| } else { | |
| isPlaying = true | |
| if (tonearmAngle.value < TurntableDesign.TonearmStartAngle) coroutineScope.launch { | |
| tonearmAngle.animateTo( | |
| TurntableDesign.TonearmStartAngle, | |
| tween(1000) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| ) | |
| } | |
| .pointerInput(Unit) { | |
| detectDragGestures( | |
| onDragStart = { offset -> | |
| if (offset.x > size.width * 0.75f && offset.y > size.height * 0.55f) { | |
| isDraggingFader = true | |
| } else if (offset.x > size.width * 0.45f && offset.y < size.height * 0.85f) { | |
| isDraggingTonearm = true; isPlaying = | |
| false; coroutineScope.launch { tonearmAngle.stop() } | |
| } | |
| }, | |
| onDrag = { change, dragAmount -> | |
| if (isDraggingFader) { | |
| val newPos = | |
| (faderPosition + (dragAmount.y / (size.height * 0.28f))).coerceIn( | |
| 0f, | |
| 1f | |
| ) | |
| if (abs(newPos - 0.5f) < 0.04f) { | |
| if (faderPosition != 0.5f) hapticFeedback.performHapticFeedback( | |
| HapticFeedbackType.LongPress | |
| ); faderPosition = 0.5f | |
| } else faderPosition = newPos | |
| } else if (isDraggingTonearm) { | |
| val pivot = | |
| Offset(size.width * 0.84f, size.height * 0.22f) | |
| val prevAngle = atan2( | |
| change.previousPosition.y - pivot.y, | |
| change.previousPosition.x - pivot.x | |
| ) | |
| val newAngle = atan2( | |
| change.position.y - pivot.y, | |
| change.position.x - pivot.x | |
| ) | |
| var deltaDeg = | |
| Math.toDegrees((newAngle - prevAngle).toDouble()) | |
| .toFloat() | |
| if (deltaDeg > 180) deltaDeg -= 360 | |
| if (deltaDeg < -180) deltaDeg += 360 | |
| coroutineScope.launch { | |
| tonearmAngle.snapTo( | |
| (tonearmAngle.value + deltaDeg).coerceIn( | |
| TurntableDesign.TonearmRestAngle - 4f, | |
| TurntableDesign.TonearmEndAngle + 2f | |
| ) | |
| ) | |
| } | |
| } | |
| }, | |
| onDragEnd = { | |
| isDraggingFader = false | |
| if (isDraggingTonearm) { | |
| isDraggingTonearm = | |
| false; if (tonearmAngle.value >= TurntableDesign.TonearmStartAngle) isPlaying = | |
| true else coroutineScope.launch { | |
| tonearmAngle.animateTo( | |
| TurntableDesign.TonearmRestAngle, | |
| tween(500) | |
| ) | |
| } | |
| } | |
| }, | |
| onDragCancel = { | |
| isDraggingFader = false; isDraggingTonearm = false | |
| } | |
| ) | |
| } | |
| ) { | |
| val center = Offset(size.width * 0.46f, size.height * 0.46f) | |
| drawTonearmRest(size) | |
| drawAnimatedTonearm(size, tonearmAngle.value, center) | |
| drawSkeuomorphicControls(size, center, isPlaying, startButtonTravel) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // ============================================================================ | |
| // NEW PLAYER UI COMPONENTS | |
| // ============================================================================ | |
| /** Scrollable track listing that auto-scrolls to and highlights the active track. Each [TrackItem] is extracted for skip optimization. */ | |
| @Composable | |
| fun EditorialTracklist(album: VinylAlbum, activeTrackIndex: Int) { | |
| val scrollState = rememberScrollState() | |
| LaunchedEffect(activeTrackIndex) { | |
| if (activeTrackIndex >= 0) { | |
| scrollState.animateScrollTo(activeTrackIndex * 100) | |
| } | |
| } | |
| Column( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .verticalScroll(scrollState) | |
| .padding(vertical = 8.dp) | |
| ) { | |
| Text( | |
| text = "SIDE A", | |
| color = Color.Black.copy(alpha = 0.3f), | |
| fontSize = 11.sp, | |
| fontWeight = FontWeight.Bold, | |
| letterSpacing = 2.sp, | |
| modifier = Modifier.padding(bottom = 12.dp) | |
| ) | |
| album.tracks.forEachIndexed { index, track -> | |
| TrackItem( | |
| index = index, | |
| track = track, | |
| isActive = index == activeTrackIndex | |
| ) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun TrackItem(index: Int, track: String, isActive: Boolean) { | |
| val animatedOffsetX by animateDpAsState( | |
| if (isActive) 12.dp else 0.dp, | |
| spring(stiffness = Spring.StiffnessLow), | |
| label = "offset" | |
| ) | |
| val textColor by animateColorAsState( | |
| if (isActive) Color.Black else Color(0xFFB3B3B3), | |
| tween(300), | |
| label = "textColor" | |
| ) | |
| val fontSize by animateFloatAsState( | |
| if (isActive) 22f else 16f, | |
| spring(stiffness = Spring.StiffnessLow), | |
| label = "fontSize" | |
| ) | |
| Row( | |
| verticalAlignment = Alignment.CenterVertically, | |
| modifier = Modifier | |
| .offset(x = animatedOffsetX) | |
| .padding(vertical = 6.dp) | |
| ) { | |
| Text( | |
| text = "0${index + 1}", | |
| fontSize = 12.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = if (isActive) Color(0xFFFF5500) else Color(0xFFCCCCCC) | |
| ) | |
| Spacer(Modifier.width(16.dp)) | |
| Text( | |
| text = track, | |
| style = if (isActive) ActiveTrackTextStyle else InactiveTrackTextStyle, | |
| fontSize = fontSize.sp, | |
| fontWeight = if (isActive) FontWeight.Black else FontWeight.Medium, | |
| color = textColor, | |
| letterSpacing = (-0.5).sp, | |
| maxLines = 1 | |
| ) | |
| } | |
| } | |
| @Composable | |
| fun SharedTransitionScope.PlayerHeader( | |
| album: VinylAlbum, | |
| animatedVisibilityScope: AnimatedVisibilityScope, | |
| onBack: () -> Unit | |
| ) { | |
| val systemBars = WindowInsets.systemBars.asPaddingValues() | |
| Row( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding( | |
| top = systemBars.calculateTopPadding() + 8.dp, | |
| start = 8.dp, | |
| end = 16.dp, | |
| bottom = 8.dp | |
| ), | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| IconButton(onClick = onBack) { | |
| Icon( | |
| imageVector = Icons.Default.ArrowBack, | |
| contentDescription = "Back", | |
| tint = Color.Black | |
| ) | |
| } | |
| Spacer(modifier = Modifier.width(8.dp)) | |
| Column(modifier = Modifier.weight(1f)) { | |
| Text( | |
| text = album.title, | |
| color = Color.Black, | |
| fontSize = 20.sp, | |
| fontWeight = FontWeight.ExtraBold, | |
| maxLines = 1, | |
| letterSpacing = 0.2.sp, | |
| modifier = Modifier.sharedElement( | |
| rememberSharedContentState("title_${album.id}"), | |
| animatedVisibilityScope | |
| ) | |
| ) | |
| Spacer(modifier = Modifier.height(2.dp)) | |
| Text( | |
| text = album.artist, | |
| color = Color(0xFFAAAAAA), | |
| fontSize = 14.sp, | |
| fontWeight = FontWeight.Medium, | |
| maxLines = 1, | |
| modifier = Modifier.sharedElement( | |
| rememberSharedContentState("artist_${album.id}"), | |
| animatedVisibilityScope | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| /** Circular brushed-metal button with an engraved icon. Used for playback controls. */ | |
| @Composable | |
| fun MetallicControlButton( | |
| icon: ImageVector, | |
| size: Dp, | |
| iconTint: Color, | |
| onClick: () -> Unit | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .size(size) | |
| .shadow( | |
| elevation = 8.dp, | |
| shape = CircleShape, | |
| spotColor = Color(0x99000000), | |
| ambientColor = Color(0x44000000) | |
| ) | |
| .clip(CircleShape) | |
| .background( | |
| Brush.linearGradient(listOf(Color(0xFFFFFFFF), Color(0xFFBDBDBD))) | |
| ) | |
| .clickable( | |
| interactionSource = remember { MutableInteractionSource() }, | |
| indication = null, | |
| onClick = onClick | |
| ), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| // Outer Rim Highlight | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .border( | |
| width = 1.dp, | |
| brush = Brush.linearGradient(listOf(Color.White, Color.Transparent)), | |
| shape = CircleShape | |
| ) | |
| ) | |
| // Inner well / Machined center | |
| Box( | |
| modifier = Modifier | |
| .size(size * 0.85f) | |
| .clip(CircleShape) | |
| .background( | |
| Brush.radialGradient(listOf(Color(0xFFE0E0E0), Color(0xFFAAAAAA))) | |
| ) | |
| .border( | |
| width = 1.dp, | |
| color = Color(0xFF999999), | |
| shape = CircleShape | |
| ), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| // Engraved Icon Drop Shadow | |
| Icon( | |
| imageVector = icon, | |
| contentDescription = null, | |
| tint = Color.White, | |
| modifier = Modifier | |
| .size(size * 0.45f) | |
| .offset(y = 1.dp) | |
| ) | |
| // Actual Icon | |
| Icon( | |
| imageVector = icon, | |
| contentDescription = null, | |
| tint = iconTint, | |
| modifier = Modifier.size(size * 0.45f) | |
| ) | |
| } | |
| } | |
| } | |
| /** Bottom bar with skip/play/pause buttons. Fades in from the chassis background. */ | |
| @Composable | |
| fun MinimalControlPanel( | |
| isPlaying: Boolean, | |
| onPlayPause: () -> Unit, | |
| onSkipForward: () -> Unit, | |
| onSkipBackward: () -> Unit | |
| ) { | |
| val systemBars = WindowInsets.systemBars.asPaddingValues() | |
| val fadeBrush = remember { | |
| Brush.verticalGradient( | |
| colors = listOf( | |
| Color(0xFFF5F5F7).copy(alpha = 0f), | |
| Color(0xFFE8E8EB), | |
| Color(0xFFD0D0D6) | |
| ) | |
| ) | |
| } | |
| Box( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .background(brush = fadeBrush) | |
| .padding(bottom = systemBars.calculateBottomPadding() + 32.dp, top = 48.dp), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Row( | |
| verticalAlignment = Alignment.CenterVertically, | |
| horizontalArrangement = Arrangement.spacedBy(32.dp) | |
| ) { | |
| MetallicControlButton( | |
| icon = Icons.Default.FastRewind, | |
| size = 56.dp, | |
| iconTint = Color(0xFF888888), | |
| onClick = onSkipBackward | |
| ) | |
| MetallicControlButton( | |
| icon = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, | |
| size = 72.dp, | |
| iconTint = if (isPlaying) Color(0xFF111111) else Color(0xFF888888), // High-contrast graphite when active | |
| onClick = onPlayPause | |
| ) | |
| MetallicControlButton( | |
| icon = Icons.Default.FastForward, | |
| size = 56.dp, | |
| iconTint = Color(0xFF888888), | |
| onClick = onSkipForward | |
| ) | |
| } | |
| } | |
| } | |
| // ============================================================================ | |
| // SHARED MASTER COMPONENTS | |
| // ============================================================================ | |
| /** Brushed-metal chassis container. Provides the silver gradient background and light/shadow overlay. */ | |
| @Composable | |
| fun TurntableChassis(modifier: Modifier = Modifier, content: @Composable () -> Unit) { | |
| val chassisBrush = remember { | |
| Brush.linearGradient( | |
| colors = listOf( | |
| Color(0xFFEBEBEB), Color(0xFFDCDCDC), Color(0xFFC4C4C4), | |
| Color(0xFFA5A5A5), Color(0xFF888888) | |
| ), | |
| start = Offset(0f, 0f), | |
| end = Offset(0f, Float.POSITIVE_INFINITY) | |
| ) | |
| } | |
| val overlayBrush = remember { | |
| Brush.linearGradient( | |
| colors = listOf( | |
| Color(0x66FFFFFF), | |
| Color.Transparent, | |
| Color.Transparent, | |
| Color(0x66000000) | |
| ), | |
| start = Offset(0f, 0f), end = Offset(0f, Float.POSITIVE_INFINITY) | |
| ) | |
| } | |
| Box( | |
| modifier = modifier | |
| .clip(RoundedCornerShape(8.dp)) | |
| .background(chassisBrush) | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(overlayBrush) | |
| ) | |
| content() | |
| } | |
| } | |
| /** | |
| * Canvas-drawn vinyl disc with grooves, label, and spindle hole. | |
| * | |
| * Uses [drawWithCache] so all Brushes, Strokes, and geometry are computed once per size change. | |
| * Only the rotation (read inside [onDrawBehind]) changes per frame — no recomposition needed. | |
| * | |
| * @param surfaceRotationDegrees lambda to defer the rotation state read to the draw phase, avoiding recomposition. | |
| * @param platterCenterOffset normalized (0..1) position of the disc center within the composable bounds. | |
| */ | |
| @Composable | |
| fun VinylRecord( | |
| modifier: Modifier = Modifier, | |
| surfaceRotationDegrees: () -> Float, | |
| platterCenterOffset: Offset, | |
| album: VinylAlbum | |
| ) { | |
| val isClear = album.discColor.alpha < 1f | |
| val isGold = album.discColor == Color(0xFFD4AF37) | |
| val isBlack = album.discColor == Color(0xFF111111) | |
| val isColoredOpaque = !isClear && !isGold && !isBlack | |
| Spacer(modifier = modifier.drawWithCache { | |
| val center = Offset(size.width * platterCenterOffset.x, size.height * platterCenterOffset.y) | |
| val vinylRadius = size.minDimension * 0.42f | |
| val innerLabelRadius = vinylRadius * 0.32f | |
| val baseBrush = Brush.radialGradient( | |
| colors = listOf( | |
| album.discColor, | |
| album.discColor.copy(alpha = album.discColor.alpha * 0.8f) | |
| ), | |
| center = center, radius = vinylRadius | |
| ) | |
| val sweepColors = when { | |
| isClear -> listOf( | |
| Color.White.copy(0.7f), Color.Transparent, Color.Black.copy(0.1f), | |
| Color.Transparent, Color.White.copy(0.7f), Color.Transparent, | |
| Color.Black.copy(0.1f), Color.Transparent, Color.White.copy(0.7f) | |
| ) | |
| isGold -> listOf( | |
| Color.White.copy(0.8f), Color(0xFF5C3A00).copy(0.6f), Color.Transparent, | |
| Color(0xFF5C3A00).copy(0.6f), Color.White.copy(0.8f), Color(0xFF5C3A00).copy(0.6f), | |
| Color.Transparent, Color(0xFF5C3A00).copy(0.6f), Color.White.copy(0.8f) | |
| ) | |
| isColoredOpaque -> listOf( | |
| Color.White.copy(0.4f), Color.Transparent, Color.Black.copy(0.3f), | |
| Color.Transparent, Color.White.copy(0.4f), Color.Transparent, | |
| Color.Black.copy(0.3f), Color.Transparent, Color.White.copy(0.4f) | |
| ) | |
| else -> listOf( | |
| Color.White.copy(0.35f), Color.Transparent, Color.Black.copy(0.8f), | |
| Color.Transparent, Color.White.copy(0.35f), Color.Transparent, | |
| Color.Black.copy(0.8f), Color.Transparent, Color.White.copy(0.35f) | |
| ) | |
| } | |
| val sweepBrush = Brush.sweepGradient(sweepColors, center = center) | |
| val edgeRefraction = Brush.radialGradient( | |
| colors = listOf( | |
| Color.Transparent, | |
| Color.White.copy(if (isClear || isColoredOpaque) 0.4f else 0.15f), | |
| Color.Black.copy(0.5f) | |
| ), center = center, radius = vinylRadius | |
| ) | |
| val borderColor = Color.Black.copy(alpha = 0.5f) | |
| val borderStroke = Stroke(width = 1.dp.toPx()) | |
| val grooveColor = if (isGold) Color(0xFF5C3A00) else Color.Black | |
| val grooveDarkColor = | |
| grooveColor.copy(alpha = if (isClear) 0.08f else if (isColoredOpaque) 0.2f else 0.35f) | |
| val grooveLightColor = | |
| Color.White.copy(alpha = if (isClear || isColoredOpaque) 0.15f else 0.08f) | |
| val grooveDarkStroke = Stroke(width = 0.5.dp.toPx()) | |
| val grooveLightStroke = Stroke(width = 0.2.dp.toPx()) | |
| val halfDpPx = 0.5.dp.toPx() | |
| val trackGapColor = | |
| grooveColor.copy(alpha = if (isClear) 0.05f else if (isColoredOpaque) 0.15f else 0.3f) | |
| val trackGapStroke3 = Stroke(width = 3.dp.toPx()) | |
| val trackGapStroke4 = Stroke(width = 4.dp.toPx()) | |
| val trackGapStroke2 = Stroke(width = 2.dp.toPx()) | |
| val oneDpPx = 1.dp.toPx() | |
| val twoDpPx = 2.dp.toPx() | |
| val labelShadowCenter = center.copy(x = center.x + oneDpPx, y = center.y + twoDpPx) | |
| val labelBrush = Brush.linearGradient(listOf(album.labelColorTop, album.labelColorBottom)) | |
| val labelArcTopLeft = | |
| Offset(center.x - innerLabelRadius * 0.8f, center.y - innerLabelRadius * 0.8f) | |
| val labelArcSize = Size(innerLabelRadius * 1.6f, innerLabelRadius * 1.6f) | |
| val labelRectTopLeft = | |
| Offset(center.x + innerLabelRadius * 0.3f, center.y - innerLabelRadius * 0.1f) | |
| val labelRectSize = Size(innerLabelRadius * 0.4f, innerLabelRadius * 0.2f) | |
| val labelRectCorner = CornerRadius(twoDpPx) | |
| val spindleShadowCenter = center.copy(x = center.x + 1.5.dp.toPx(), y = center.y + twoDpPx) | |
| onDrawBehind { | |
| drawCircle(brush = baseBrush, radius = vinylRadius, center = center) | |
| drawCircle(brush = sweepBrush, radius = vinylRadius, center = center) | |
| drawCircle(brush = edgeRefraction, radius = vinylRadius, center = center) | |
| drawCircle( | |
| color = borderColor, | |
| radius = vinylRadius, | |
| center = center, | |
| style = borderStroke | |
| ) | |
| rotate(surfaceRotationDegrees(), pivot = center) { | |
| for (i in 0..80) { | |
| val r = vinylRadius * (0.98f - (i * 0.008f)) | |
| if (r > innerLabelRadius) { | |
| drawCircle( | |
| color = grooveDarkColor, | |
| radius = r, | |
| center = center, | |
| style = grooveDarkStroke | |
| ) | |
| drawCircle( | |
| color = grooveLightColor, | |
| radius = r - halfDpPx, | |
| center = center, | |
| style = grooveLightStroke | |
| ) | |
| } | |
| } | |
| drawCircle( | |
| color = trackGapColor, | |
| radius = vinylRadius * 0.80f, | |
| center = center, | |
| style = trackGapStroke3 | |
| ) | |
| drawCircle( | |
| color = trackGapColor, | |
| radius = vinylRadius * 0.65f, | |
| center = center, | |
| style = trackGapStroke4 | |
| ) | |
| drawCircle( | |
| color = trackGapColor, | |
| radius = vinylRadius * 0.45f, | |
| center = center, | |
| style = trackGapStroke2 | |
| ) | |
| drawCircle( | |
| color = Color.Black.copy(alpha = 0.3f), | |
| radius = innerLabelRadius, | |
| center = labelShadowCenter | |
| ) | |
| drawCircle(brush = labelBrush, radius = innerLabelRadius, center = center) | |
| drawArc( | |
| color = Color(0xFFA53B3B), | |
| startAngle = 135f, | |
| sweepAngle = 90f, | |
| useCenter = false, | |
| topLeft = labelArcTopLeft, | |
| size = labelArcSize, | |
| style = trackGapStroke2 | |
| ) | |
| drawRoundRect( | |
| color = Color(0xFF222222), topLeft = labelRectTopLeft, | |
| size = labelRectSize, cornerRadius = labelRectCorner | |
| ) | |
| } | |
| drawCircle( | |
| color = Color.Black.copy(alpha = 0.6f), | |
| radius = innerLabelRadius * 0.12f, | |
| center = spindleShadowCenter | |
| ) | |
| drawCircle( | |
| color = Color(0xFF111111), | |
| radius = innerLabelRadius * 0.10f, | |
| center = center | |
| ) | |
| drawCircle( | |
| color = Color.White.copy(alpha = 0.3f), | |
| radius = innerLabelRadius * 0.10f, | |
| center = center, | |
| style = grooveDarkStroke | |
| ) | |
| } | |
| }) | |
| } | |
| /** Draws the pitch fader: slot, knob with shadow, center-lock LED indicator. */ | |
| fun DrawScope.drawChassisDetails(size: Size, faderPosition: Float) { | |
| val faderWidth = size.width * 0.05f | |
| val faderTopLeft = Offset(size.width * 0.89f, size.height * 0.65f) | |
| val faderHeight = size.height * 0.28f | |
| drawRoundRect( | |
| color = Color(0xFF444444), | |
| topLeft = faderTopLeft, | |
| size = Size(faderWidth, faderHeight), | |
| cornerRadius = CornerRadius(2.dp.toPx()) | |
| ) | |
| drawRoundRect( | |
| color = Color(0xFF111111), | |
| topLeft = faderTopLeft, | |
| size = Size(faderWidth, faderHeight), | |
| cornerRadius = CornerRadius(2.dp.toPx()), | |
| style = Stroke(width = 1.dp.toPx()) | |
| ) | |
| val slotWidth = 3.dp.toPx() | |
| drawRoundRect( | |
| color = Color(0xFF000000), | |
| topLeft = Offset( | |
| faderTopLeft.x + (faderWidth / 2) - (slotWidth / 2), | |
| faderTopLeft.y + 8.dp.toPx() | |
| ), | |
| size = Size(slotWidth, faderHeight - 16.dp.toPx()), | |
| cornerRadius = CornerRadius(1.dp.toPx()) | |
| ) | |
| drawLine( | |
| color = Color(0x88FFFFFF), | |
| start = Offset(faderTopLeft.x - 2.dp.toPx(), faderTopLeft.y + faderHeight / 2), | |
| end = Offset(faderTopLeft.x + faderWidth + 2.dp.toPx(), faderTopLeft.y + faderHeight / 2), | |
| strokeWidth = 1.dp.toPx() | |
| ) | |
| val isLocked = abs(faderPosition - 0.5f) < 0.01f | |
| val ledCenter = Offset(faderTopLeft.x - 10.dp.toPx(), faderTopLeft.y + faderHeight / 2) | |
| drawCircle( | |
| color = if (isLocked) Color(0xFF00FF00) else Color(0xFF003300), | |
| radius = 2.dp.toPx(), | |
| center = ledCenter | |
| ) | |
| if (isLocked) drawCircle( | |
| brush = Brush.radialGradient( | |
| listOf( | |
| Color(0x9900FF00), | |
| Color.Transparent | |
| ), center = ledCenter, radius = 8.dp.toPx() | |
| ), radius = 8.dp.toPx(), center = ledCenter | |
| ) | |
| val knobWidth = faderWidth + 16.dp.toPx() | |
| val knobHeight = 24.dp.toPx() | |
| val knobTopLeft = Offset( | |
| faderTopLeft.x - 8.dp.toPx(), | |
| faderTopLeft.y + ((faderHeight - knobHeight) * faderPosition) | |
| ) | |
| drawIntoCanvas { canvas -> | |
| canvas.nativeCanvas.drawRoundRect( | |
| knobTopLeft.x, | |
| knobTopLeft.y + 4.dp.toPx(), | |
| knobTopLeft.x + knobWidth, | |
| knobTopLeft.y + knobHeight + 6.dp.toPx(), | |
| 2.dp.toPx(), | |
| 2.dp.toPx(), | |
| FaderKnobShadowPaint | |
| ) | |
| } | |
| drawRoundRect( | |
| brush = Brush.linearGradient( | |
| listOf( | |
| Color(0xFFFFFFFF), | |
| Color(0xFFBDBDBD), | |
| Color(0xFF777777) | |
| ) | |
| ), | |
| topLeft = knobTopLeft, | |
| size = Size(knobWidth, knobHeight), | |
| cornerRadius = CornerRadius(1.dp.toPx()) | |
| ) | |
| drawLine( | |
| color = Color(0xFFAA0000), | |
| start = Offset(faderTopLeft.x - 4.dp.toPx(), knobTopLeft.y + knobHeight / 2), | |
| end = Offset(faderTopLeft.x + faderWidth + 4.dp.toPx(), knobTopLeft.y + knobHeight / 2), | |
| strokeWidth = 2.dp.toPx() | |
| ) | |
| } | |
| /** Draws the metallic platter rim with strobe dots and a red LED reflection. */ | |
| fun DrawScope.drawStrobePlatterBand(size: Size, center: Offset, radius: Float) { | |
| drawCircle( | |
| brush = Brush.sweepGradient( | |
| 0.0f to Color(0xFFBDBDBD), | |
| 0.25f to Color(0xFF666666), | |
| 0.5f to Color(0xFFEEEEEE), | |
| 0.75f to Color(0xFF555555), | |
| 1.0f to Color(0xFFBDBDBD), | |
| center = center | |
| ), radius = radius, center = center | |
| ) | |
| drawCircle( | |
| color = Color(0xFF151515), | |
| radius = radius - 4.dp.toPx(), | |
| center = center, | |
| style = Stroke(width = 6.dp.toPx()) | |
| ) | |
| val strobeDash = PathEffect.dashPathEffect( | |
| floatArrayOf( | |
| (2 * PI * radius / 200).toFloat() * 0.5f, | |
| (2 * PI * radius / 200).toFloat() * 0.5f | |
| ), 0f | |
| ) | |
| drawCircle( | |
| brush = Brush.radialGradient( | |
| listOf(Color(0xFFFFFFFF), Color(0xFFAAAAAA)), | |
| center = center | |
| ), | |
| radius = radius - 4.dp.toPx(), | |
| center = center, | |
| style = Stroke(width = 6.dp.toPx(), pathEffect = strobeDash, cap = StrokeCap.Butt) | |
| ) | |
| drawCircle( | |
| color = Color(0x66000000), | |
| radius = radius - 7.dp.toPx(), | |
| center = center, | |
| style = Stroke(width = 1.dp.toPx()) | |
| ) | |
| drawCircle( | |
| brush = Brush.radialGradient( | |
| 0.0f to Color(0xFFFF1111).copy(alpha = 0.8f), | |
| 0.5f to Color(0xFFFF1111).copy(alpha = 0.0f), | |
| center = Offset( | |
| center.x + cos(135f * PI / 180f).toFloat() * radius, | |
| center.y + sin(135f * PI / 180f).toFloat() * radius | |
| ), | |
| radius = radius * 0.25f | |
| ), | |
| radius = radius - 2.dp.toPx(), | |
| center = center, | |
| style = Stroke(width = 8.dp.toPx()), | |
| blendMode = BlendMode.Screen | |
| ) | |
| } | |
| /** Draws the tonearm rest cradle (the small post the arm sits on when not playing). */ | |
| fun DrawScope.drawTonearmRest(size: Size) { | |
| val restCenter = Offset(size.width * 0.84f, size.height * 0.45f) | |
| val baseRadius = size.minDimension * 0.035f | |
| drawIntoCanvas { canvas -> | |
| canvas.nativeCanvas.drawCircle( | |
| restCenter.x + 4.dp.toPx(), | |
| restCenter.y + 4.dp.toPx(), | |
| baseRadius, | |
| TonearmRestShadowPaint | |
| ) | |
| } | |
| drawCircle( | |
| brush = Brush.sweepGradient( | |
| listOf( | |
| Color(0xFF222222), | |
| Color(0xFF555555), | |
| Color(0xFF111111), | |
| Color(0xFF555555), | |
| Color(0xFF222222) | |
| ), center = restCenter | |
| ), radius = baseRadius, center = restCenter | |
| ) | |
| drawCircle( | |
| brush = Brush.radialGradient( | |
| listOf(Color(0xFFE0E0E0), Color(0xFF666666)), | |
| center = restCenter | |
| ), radius = baseRadius * 0.6f, center = restCenter, style = Stroke(width = 3.dp.toPx()) | |
| ) | |
| val arcTopLeft = Offset(restCenter.x - baseRadius * 0.5f, restCenter.y - baseRadius * 0.8f) | |
| val arcSize = Size(baseRadius, baseRadius * 1.5f) | |
| drawArc( | |
| color = Color(0x88000000), | |
| startAngle = -20f, | |
| sweepAngle = -140f, | |
| useCenter = false, | |
| topLeft = Offset(arcTopLeft.x + 2.dp.toPx(), arcTopLeft.y + 3.dp.toPx()), | |
| size = arcSize, | |
| style = Stroke(width = 5.dp.toPx(), cap = StrokeCap.Round) | |
| ) | |
| drawArc( | |
| color = Color(0xFF111111), | |
| startAngle = -20f, | |
| sweepAngle = -140f, | |
| useCenter = false, | |
| topLeft = arcTopLeft, | |
| size = arcSize, | |
| style = Stroke(width = 5.dp.toPx(), cap = StrokeCap.Round) | |
| ) | |
| drawArc( | |
| brush = Brush.linearGradient(listOf(Color(0xFF777777), Color.Transparent)), | |
| startAngle = -20f, | |
| sweepAngle = -140f, | |
| useCenter = false, | |
| topLeft = Offset(arcTopLeft.x - 0.5.dp.toPx(), arcTopLeft.y - 0.5.dp.toPx()), | |
| size = arcSize, | |
| style = Stroke(width = 1.dp.toPx(), cap = StrokeCap.Round) | |
| ) | |
| } | |
| /** Draws the tonearm: pivot base, curved arm tube, and headshell. Rotated by [trackingAngle] around the pivot. */ | |
| fun DrawScope.drawAnimatedTonearm(size: Size, trackingAngle: Float, platterCenter: Offset) { | |
| val mainPivot = Offset(size.width * 0.84f, size.height * 0.22f) | |
| val vinylRadius = size.minDimension * 0.42f | |
| val baseRadius = size.minDimension * 0.09f | |
| drawIntoCanvas { canvas -> | |
| canvas.nativeCanvas.drawCircle( | |
| mainPivot.x + 4.dp.toPx(), | |
| mainPivot.y + 6.dp.toPx(), | |
| baseRadius + 2.dp.toPx(), | |
| TonearmBaseShadowPaint | |
| ) | |
| } | |
| drawCircle( | |
| brush = Brush.sweepGradient( | |
| 0.0f to Color(0xFF222222), | |
| 0.25f to Color(0xFF555555), | |
| 0.5f to Color(0xFF1A1A1A), | |
| 0.75f to Color(0xFF444444), | |
| 1.0f to Color(0xFF222222), | |
| center = mainPivot | |
| ), radius = baseRadius, center = mainPivot | |
| ) | |
| rotate(trackingAngle, pivot = mainPivot) { | |
| val armPath = Path().apply { | |
| moveTo( | |
| mainPivot.x, | |
| mainPivot.y | |
| ); cubicTo( | |
| mainPivot.x + vinylRadius * 0.35f, | |
| mainPivot.y + vinylRadius * 0.88f * 0.3f, | |
| mainPivot.x + vinylRadius * 0.05f, | |
| mainPivot.y + vinylRadius * 0.88f * 0.65f, | |
| mainPivot.x - vinylRadius * 0.15f, | |
| mainPivot.y + vinylRadius * 0.88f | |
| ) | |
| } | |
| drawPath( | |
| path = armPath, | |
| color = Color(0xFFB3B3B3), | |
| style = Stroke(width = 8.dp.toPx(), cap = StrokeCap.Round) | |
| ) | |
| rotate( | |
| 28f, | |
| pivot = Offset(mainPivot.x - vinylRadius * 0.15f, mainPivot.y + vinylRadius * 0.88f) | |
| ) { | |
| val hsWidth = vinylRadius * 0.14f | |
| drawRoundRect( | |
| brush = Brush.linearGradient( | |
| listOf( | |
| Color(0xFF262626), | |
| Color(0xFF0A0A0A) | |
| ) | |
| ), | |
| topLeft = Offset( | |
| (mainPivot.x - vinylRadius * 0.15f) - hsWidth / 2f, | |
| (mainPivot.y + vinylRadius * 0.88f) + 8.dp.toPx() | |
| ), | |
| size = Size(hsWidth, vinylRadius * 0.28f), | |
| cornerRadius = CornerRadius(2.dp.toPx()) | |
| ) | |
| } | |
| } | |
| drawCircle( | |
| brush = Brush.radialGradient(listOf(Color(0xFFE0E0E0), Color(0xFF444444))), | |
| radius = baseRadius * 0.45f, | |
| center = mainPivot | |
| ) | |
| } | |
| /** Draws the start/stop tower (knurled knob with LED) and the mechanical push button. [buttonTravel] 0→1 animates the press depth. */ | |
| fun DrawScope.drawSkeuomorphicControls( | |
| size: Size, | |
| platterCenter: Offset, | |
| isPlaying: Boolean, | |
| buttonTravel: Float | |
| ) { | |
| val towerBaseCenter = Offset(size.width * 0.14f, size.height * 0.82f) | |
| val towerRadius = size.minDimension * 0.06f | |
| drawIntoCanvas { canvas -> | |
| canvas.nativeCanvas.drawCircle( | |
| towerBaseCenter.x + 4.dp.toPx(), | |
| towerBaseCenter.y + 4.dp.toPx(), | |
| towerRadius, | |
| ControlsTowerShadowPaint | |
| ) | |
| } | |
| drawCircle( | |
| brush = Brush.radialGradient( | |
| listOf(Color(0xFFE0E0E0), Color(0xFF888888)), | |
| center = towerBaseCenter | |
| ), radius = towerRadius, center = towerBaseCenter | |
| ) | |
| drawCircle( | |
| brush = Brush.sweepGradient(*KnurlingColorStops, center = towerBaseCenter), | |
| radius = towerRadius * 0.85f, | |
| center = towerBaseCenter | |
| ) | |
| drawCircle( | |
| brush = Brush.radialGradient( | |
| listOf(Color(0xFF333333), Color(0xFF111111)), | |
| center = towerBaseCenter | |
| ), radius = towerRadius * 0.75f, center = towerBaseCenter | |
| ) | |
| drawCircle( | |
| brush = Brush.radialGradient( | |
| 0.0f to Color(0xFFFF2222).copy(alpha = if (isPlaying) 0.8f else 0.3f), | |
| 1.0f to Color.Transparent, | |
| center = Offset( | |
| towerBaseCenter.x + towerRadius * 1.5f, | |
| towerBaseCenter.y - towerRadius * 1.5f | |
| ), | |
| radius = towerRadius * 2.5f | |
| ), | |
| radius = towerRadius * 2.5f, | |
| center = Offset( | |
| towerBaseCenter.x + towerRadius * 1.5f, | |
| towerBaseCenter.y - towerRadius * 1.5f | |
| ) | |
| ) | |
| rotate(-48f, pivot = towerBaseCenter) { | |
| val path = Path().apply { | |
| moveTo( | |
| towerBaseCenter.x - towerRadius * 0.1f, | |
| towerBaseCenter.y - towerRadius * 0.4f | |
| ); lineTo( | |
| towerBaseCenter.x + towerRadius * 0.8f, | |
| towerBaseCenter.y - towerRadius * 0.2f | |
| ); lineTo( | |
| towerBaseCenter.x + towerRadius * 0.8f, | |
| towerBaseCenter.y + towerRadius * 0.2f | |
| ); lineTo( | |
| towerBaseCenter.x - towerRadius * 0.1f, | |
| towerBaseCenter.y + towerRadius * 0.4f | |
| ); close() | |
| } | |
| drawPath(path = path, color = Color(0x99000000), style = Fill); drawPath( | |
| path = path, | |
| brush = Brush.linearGradient( | |
| listOf(Color(0xFF444444), Color(0xFF151515)), | |
| start = Offset(towerBaseCenter.x, towerBaseCenter.y - towerRadius * 0.4f), | |
| end = Offset( | |
| towerBaseCenter.x + towerRadius * 0.8f, | |
| towerBaseCenter.y + towerRadius * 0.2f | |
| ) | |
| ) | |
| ) | |
| drawLine( | |
| color = Color(0x66FFFFFF), | |
| start = Offset( | |
| towerBaseCenter.x - towerRadius * 0.1f, | |
| towerBaseCenter.y - towerRadius * 0.4f | |
| ), | |
| end = Offset( | |
| towerBaseCenter.x + towerRadius * 0.8f, | |
| towerBaseCenter.y - towerRadius * 0.2f | |
| ), | |
| strokeWidth = 1.dp.toPx() | |
| ) | |
| val ledX = towerBaseCenter.x + towerRadius * 0.8f; | |
| val ledY = towerBaseCenter.y; drawCircle( | |
| color = if (isPlaying) Color(0xFFFF3333) else Color( | |
| 0xFFAA0000 | |
| ), radius = 1.5.dp.toPx(), center = Offset(ledX, ledY) | |
| ) | |
| if (isPlaying) { | |
| drawCircle( | |
| color = Color.White.copy(alpha = 0.9f), | |
| radius = 0.5.dp.toPx(), | |
| center = Offset(ledX, ledY) | |
| ); drawCircle( | |
| brush = Brush.radialGradient( | |
| listOf( | |
| Color(0xFFFF3333).copy(alpha = 0.8f), | |
| Color.Transparent | |
| ), center = Offset(ledX, ledY), radius = 8.dp.toPx() | |
| ), radius = 8.dp.toPx(), center = Offset(ledX, ledY) | |
| ) | |
| } | |
| } | |
| val btnWidth = size.width * 0.13f; | |
| val btnHeight = size.height * 0.075f; | |
| val btnTopLeft = Offset(size.width * 0.07f, size.height * 0.90f) | |
| drawRoundRect( | |
| color = Color(0x99000000), | |
| topLeft = btnTopLeft.copy(x = btnTopLeft.x - 1.dp.toPx(), y = btnTopLeft.y - 1.dp.toPx()), | |
| size = Size(btnWidth + 2.dp.toPx(), btnHeight + 2.dp.toPx()), | |
| cornerRadius = CornerRadius(4.dp.toPx()) | |
| ) | |
| drawRoundRect( | |
| color = Color(0xCCFFFFFF), | |
| topLeft = btnTopLeft.copy(y = btnTopLeft.y + 1.dp.toPx()), | |
| size = Size(btnWidth, btnHeight), | |
| cornerRadius = CornerRadius(4.dp.toPx()) | |
| ); drawRoundRect( | |
| color = Color(0xFF0A0A0A), | |
| topLeft = btnTopLeft, | |
| size = Size(btnWidth, btnHeight), | |
| cornerRadius = CornerRadius(4.dp.toPx()) | |
| ) | |
| clipPath(Path().apply { | |
| addRoundRect( | |
| RoundRect( | |
| Rect(btnTopLeft, Size(btnWidth, btnHeight)), | |
| CornerRadius(4.dp.toPx()) | |
| ) | |
| ) | |
| }) { | |
| drawIntoCanvas { canvas -> | |
| val paint = android.graphics.Paint().apply { | |
| color = android.graphics.Color.argb(255, 0, 0, 0); maskFilter = | |
| BlurMaskFilter(6.dp.toPx(), BlurMaskFilter.Blur.NORMAL) | |
| }; canvas.nativeCanvas.drawRoundRect( | |
| btnTopLeft.x - 4.dp.toPx(), | |
| btnTopLeft.y - 4.dp.toPx(), | |
| btnTopLeft.x + btnWidth, | |
| btnTopLeft.y + btnHeight, | |
| 4.dp.toPx(), | |
| 4.dp.toPx(), | |
| paint | |
| ) | |
| } | |
| } | |
| val bW = btnWidth - (1.5.dp.toPx() * 2); | |
| val bH = btnHeight - (1.5.dp.toPx() * 2); | |
| val bTopLeft = | |
| btnTopLeft.copy(x = btnTopLeft.x + 1.5.dp.toPx(), y = btnTopLeft.y + 1.5.dp.toPx()); | |
| val buttonCenter = Offset(bTopLeft.x + bW / 2f, bTopLeft.y + bH / 2f) | |
| scale(scale = 1f - (0.03f * buttonTravel), pivot = buttonCenter) { | |
| drawIntoCanvas { canvas -> | |
| val shadowPaint = android.graphics.Paint().apply { | |
| color = android.graphics.Color.argb( | |
| (200 * (1f - buttonTravel * 0.3f)).toInt(), | |
| 0, | |
| 0, | |
| 0 | |
| ); maskFilter = BlurMaskFilter( | |
| 4.dp.toPx() * (1f - buttonTravel) + 1.dp.toPx(), | |
| BlurMaskFilter.Blur.NORMAL | |
| ) | |
| }; canvas.nativeCanvas.drawRoundRect( | |
| bTopLeft.x + 1.5.dp.toPx() * (1f - buttonTravel), | |
| bTopLeft.y + 3.dp.toPx() * (1f - buttonTravel), | |
| bTopLeft.x + bW + 1.5.dp.toPx() * (1f - buttonTravel), | |
| bTopLeft.y + bH + 3.dp.toPx() * (1f - buttonTravel), | |
| 3.dp.toPx(), | |
| 3.dp.toPx(), | |
| shadowPaint | |
| ) | |
| } | |
| drawRoundRect( | |
| brush = Brush.linearGradient( | |
| colors = listOf( | |
| Color(0xFFFAFAFA).copy(alpha = 1f - (buttonTravel * 0.12f)), | |
| Color(0xFFBDBDBD).copy(alpha = 1f - (buttonTravel * 0.12f)) | |
| ), | |
| start = Offset(bTopLeft.x, bTopLeft.y), | |
| end = Offset(bTopLeft.x + bW, bTopLeft.y + bH) | |
| ), topLeft = bTopLeft, size = Size(bW, bH), cornerRadius = CornerRadius(3.dp.toPx()) | |
| ) | |
| drawRoundRect( | |
| brush = Brush.linearGradient( | |
| colors = listOf( | |
| Color.White.copy(alpha = 1f - buttonTravel), | |
| Color.Transparent, | |
| Color(0x44000000) | |
| ), | |
| start = Offset(bTopLeft.x, bTopLeft.y), | |
| end = Offset(bTopLeft.x + bW, bTopLeft.y + bH) | |
| ), | |
| topLeft = bTopLeft, | |
| size = Size(bW, bH), | |
| cornerRadius = CornerRadius(3.dp.toPx()), | |
| style = Stroke(1.dp.toPx()) | |
| ) | |
| drawCircle( | |
| brush = Brush.verticalGradient( | |
| 0f to Color(0xFF777777), | |
| 1f to Color(0xFFFFFFFF), | |
| startY = buttonCenter.y - bH * 0.38f, | |
| endY = buttonCenter.y + bH * 0.38f | |
| ), radius = bH * 0.38f, center = buttonCenter | |
| ) | |
| for (i in 1..8) drawCircle( | |
| color = Color.Black.copy(0.03f), | |
| radius = bH * 0.38f * (i / 8f), | |
| center = buttonCenter, | |
| style = Stroke(0.5.dp.toPx()) | |
| ) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment