From 5cfca7470c38c020e3758d57c24d73812ea484be Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 26 May 2026 17:22:49 +0700 Subject: [PATCH 01/26] fix(player): suppress STATE_ENDED during track transitions (#2022) Update core submodule to fix/suppress-state-ended-fgs-drop which prevents the foreground service from being dropped between tracks. --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index bdbaf7b81..5b4595433 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit bdbaf7b813964e01c361dd8c76de04465c2efcd9 +Subproject commit 5b4595433ff42987c27bd5f6a0fd35d46e14ccf4 From 86343bf8f297bb258896bec52f810aba9ecbe3f0 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 27 May 2026 11:06:20 +0700 Subject: [PATCH 02/26] refactor: migrate metadata API to official OAuth2 flow --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 5b4595433..8cefa6115 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 5b4595433ff42987c27bd5f6a0fd35d46e14ccf4 +Subproject commit 8cefa61154c8bc9c4f6ac99fbdf98e9c10bd4543 From fa419c9caae417bf685111bbe7263d0bdb8a87db Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 27 May 2026 14:43:55 +0700 Subject: [PATCH 03/26] fix(player): improve auto crossfade duration logic and suppress during restore --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 8cefa6115..10f4c54c1 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 8cefa61154c8bc9c4f6ac99fbdf98e9c10bd4543 +Subproject commit 10f4c54c1efd473b503a667c1a171da697c9f7e1 From 77ec749b0cbe6296e32ff4ee4fc211756d1da3bd Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 27 May 2026 16:36:04 +0700 Subject: [PATCH 04/26] feat(desktop): port auto crossfade duration with gap factors, fix VLC native path and volume bump --- core | 2 +- desktopApp/build.gradle.kts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/core b/core index 10f4c54c1..2e9ddea35 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 10f4c54c1efd473b503a667c1a171da697c9f7e1 +Subproject commit 2e9ddea35f659cb2eb6d64848fc76d696a7d5a0f diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 538e3b108..62a9d4a91 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -247,11 +247,14 @@ afterEvaluate { jvmArgs("--add-opens", "java.base/java.nio=ALL-UNNAMED") // Pass bundled VLC natives path to the runtime for `./gradlew desktopApp:run`. + val osArch = System.getProperty("os.arch").lowercase() val osSubDir = when { - System.getProperty("os.name").contains("Mac") -> "macos" - System.getProperty("os.name").contains("Win") -> "windows" - else -> "linux" + System.getProperty("os.name").contains("Mac") -> + if (osArch.contains("aarch64")) "macos-arm64" else "macos-x64" + System.getProperty("os.name").contains("Win") -> + if (osArch.contains("aarch64")) "windows-arm64" else "windows-x64" + else -> "linux-x64" } val vlcNativesPath = rootDir.resolve("vlc-natives/$osSubDir").absolutePath systemProperty("vlc.bundled.path", vlcNativesPath) From 74713372fb078e5d9954b065202a0e176431d4e2 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Mon, 1 Jun 2026 22:22:21 +0700 Subject: [PATCH 05/26] feat(ui): apply Apple Music redesign to Local Playlist screen on Android portrait --- .../ui/component/PlaylistThumbnail.kt | 7 + .../ui/screen/library/LocalPlaylistScreen.kt | 982 +++++++++++++----- 2 files changed, 727 insertions(+), 262 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/PlaylistThumbnail.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/PlaylistThumbnail.kt index 1e65e9327..d75503246 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/PlaylistThumbnail.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/PlaylistThumbnail.kt @@ -80,6 +80,13 @@ private fun generateGradientFromTitle(title: String): List { return listOf(color1, color2) } +/** + * Deterministic placeholder gradient colors for a playlist title — matches the + * [PlaylistThumbnail] / [painterPlaylistThumbnail] placeholder, so a screen can tint + * its background to match a local playlist that has no real artwork. + */ +fun playlistTitleGradient(title: String): List = generateGradientFromTitle(title) + // HSV to RGB conversion private fun hsvToColor(hue: Float, saturation: Float, value: Float): Color { val h = hue / 60f diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LocalPlaylistScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LocalPlaylistScreen.kt index 062c0c3a7..e7a653b32 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LocalPlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LocalPlaylistScreen.kt @@ -38,6 +38,8 @@ import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth @@ -47,11 +49,15 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.sharp.Sort +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material3.AlertDialog import androidx.compose.material3.ElevatedButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -80,11 +86,14 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.rememberGraphicsLayer import com.maxrave.simpmusic.expect.ui.toImageBitmap import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController @@ -107,9 +116,13 @@ import com.maxrave.domain.utils.FilterState import com.maxrave.domain.utils.toTrack import com.maxrave.logger.Logger import com.maxrave.simpmusic.Platform +import com.maxrave.simpmusic.expect.ui.drawBackdropCustomShape +import com.maxrave.simpmusic.expect.ui.layerBackdrop +import com.maxrave.simpmusic.expect.ui.rememberBackdrop import com.maxrave.simpmusic.extension.angledGradientBackground import com.maxrave.simpmusic.extension.displayNameRes import com.maxrave.simpmusic.extension.getColorFromPalette +import com.maxrave.simpmusic.extension.getScreenSizeInfo import com.maxrave.simpmusic.getPlatform import com.maxrave.simpmusic.ui.component.CenterLoadingBox import com.maxrave.simpmusic.ui.component.DraggableItem @@ -122,6 +135,7 @@ import com.maxrave.simpmusic.ui.component.SongFullWidthItems import com.maxrave.simpmusic.ui.component.SortPlaylistBottomSheet import com.maxrave.simpmusic.ui.component.SuggestItems import com.maxrave.simpmusic.ui.component.painterPlaylistThumbnail +import com.maxrave.simpmusic.ui.component.playlistTitleGradient import com.maxrave.simpmusic.ui.component.rememberDragDropState import com.maxrave.simpmusic.ui.theme.md_theme_dark_background import com.maxrave.simpmusic.ui.theme.seed @@ -130,6 +144,11 @@ import com.maxrave.simpmusic.viewModel.LocalPlaylistUIEvent import com.maxrave.simpmusic.viewModel.LocalPlaylistViewModel import com.maxrave.simpmusic.viewModel.SharedViewModel import com.maxrave.simpmusic.viewModel.UIEvent +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.rememberHazeState import io.github.alexzhirkevich.compottie.Compottie import io.github.alexzhirkevich.compottie.LottieCompositionSpec import io.github.alexzhirkevich.compottie.rememberLottieComposition @@ -182,6 +201,7 @@ private const val TAG = "LocalPlaylistScreen" @OptIn( ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class, + ExperimentalHazeMaterialsApi::class, ) @Composable fun LocalPlaylistScreen( @@ -334,14 +354,23 @@ fun LocalPlaylistScreen( shouldHideTopBar = !firstItemVisible } val paletteState = rememberPaletteState() + val hazeState = rememberHazeState(blurEnabled = true) var bitmap by remember { mutableStateOf(null) } + // Track which thumbnail we've already extracted a palette from. + // Prevents palette flash when LazyColumn recycles the header item on scroll — + // AsyncImage re-mount fires onSuccess again, but we skip the regenerate. + var paletteGeneratedFor by remember { + mutableStateOf(null) + } + val currentThumbnail = uiState.thumbnail LaunchedEffect(bitmap) { val bm = bitmap - if (bm != null) { + if (bm != null && paletteGeneratedFor != currentThumbnail) { paletteState.generate(bm) + paletteGeneratedFor = currentThumbnail } } @@ -353,6 +382,32 @@ fun LocalPlaylistScreen( } } + // Apple Music-inspired immersive treatment: gated to mobile portrait so tablets, + // landscape orientation, and Desktop keep the existing layout. + val screenInfo = getScreenSizeInfo() + val isMobilePortrait = getPlatform() == Platform.Android && screenInfo.wDP < screenInfo.hDP + // Apple Music-style page background. When the playlist has real artwork we pick a vivid + // swatch from its palette (getColorFromPalette). Many local playlists have NO artwork — + // AsyncImage shows the title placeholder and onSuccess never fires, so the palette stays + // null; in that case we fall back to the SAME deterministic title gradient the placeholder + // draws, so the background still matches the thumbnail. Darkened slightly for readability. + val mutedPaletteBg = + run { + val palette = paletteState.palette + val base = + if (palette != null) { + palette.getColorFromPalette() + } else { + val titleColors = playlistTitleGradient(uiState.title) + if (titleColors.size >= 2) { + lerp(titleColors[0], titleColors[1], 0.5f) + } else { + titleColors.firstOrNull() ?: md_theme_dark_background + } + } + lerp(base, md_theme_dark_background, 0.3f) + } + // Loading dialog val showLoadingDialog by viewModel.showLoadingDialog.collectAsStateWithLifecycle() if (showLoadingDialog.first) { @@ -376,7 +431,8 @@ fun LocalPlaylistScreen( modifier = Modifier .fillMaxWidth() - .background(Color.Black) + .background(if (isMobilePortrait) mutedPaletteBg else Color.Black) + .hazeSource(hazeState) .pointerInput(changingOrder) { if (!changingOrder) return@pointerInput val onDrag: (change: androidx.compose.ui.input.pointer.PointerInputChange, offset: Offset) -> Unit = @@ -443,289 +499,641 @@ fun LocalPlaylistScreen( .wrapContentHeight() .background(Color.Transparent), ) { - Box( - modifier = - Modifier - .fillMaxWidth(), -// .haze( -// hazeState, -// style = HazeMaterials.regular(), -// ), - ) { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(260.dp) - .clip( - RoundedCornerShape(8.dp), - ).angledGradientBackground(uiState.colors, 25f), - ) + if (!isMobilePortrait) { Box( modifier = Modifier - .fillMaxWidth() - .height(180.dp) - .align(Alignment.BottomCenter) - .background( - brush = - Brush.verticalGradient( - listOf( - Color.Transparent, - Color(0x75000000), - Color.Black, + .fillMaxWidth(), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(260.dp) + .clip( + RoundedCornerShape(8.dp), + ).angledGradientBackground(uiState.colors, 25f), + ) + Box( + modifier = + Modifier + .fillMaxWidth() + .height(180.dp) + .align(Alignment.BottomCenter) + .background( + brush = + Brush.verticalGradient( + listOf( + Color.Transparent, + Color(0x75000000), + Color.Black, + ), ), - ), - ), - ) + ), + ) + } } Column( Modifier .background(Color.Transparent), -// .hazeChild(hazeState, style = HazeMaterials.regular()), ) { - Row( - modifier = - Modifier - .wrapContentWidth() - .padding(16.dp) - .windowInsetsPadding(WindowInsets.statusBars), - ) { - RippleIconButton( - resId = Res.drawable.baseline_arrow_back_ios_new_24, + if (!isMobilePortrait) { + Row( + modifier = + Modifier + .wrapContentWidth() + .padding(16.dp) + .windowInsetsPadding(WindowInsets.statusBars), ) { - navController.navigateUp() + RippleIconButton( + resId = Res.drawable.baseline_arrow_back_ios_new_24, + ) { + navController.navigateUp() + } } } Column( horizontalAlignment = Alignment.Start, ) { - AsyncImage( - model = - ImageRequest - .Builder(LocalPlatformContext.current) - .data(uiState.thumbnail) - .diskCachePolicy(CachePolicy.ENABLED) - .diskCacheKey(uiState.thumbnail) - .crossfade(550) - .build(), - placeholder = painterPlaylistThumbnail(uiState.title, style = typo().labelMedium, 250.dp to 250.dp), - error = painterPlaylistThumbnail(uiState.title, style = typo().labelMedium, 250.dp to 250.dp), - fallback = painterPlaylistThumbnail(uiState.title, style = typo().labelMedium, 250.dp to 250.dp), - contentDescription = null, - contentScale = ContentScale.FillHeight, - onSuccess = { - bitmap = it.result.image.toImageBitmap() - }, - modifier = - Modifier - .height(250.dp) - .wrapContentWidth() - .align(Alignment.CenterHorizontally) - .clip( - RoundedCornerShape(8.dp), - ), - ) - Box( - modifier = - Modifier - .fillMaxWidth() - .wrapContentHeight(), - ) { - Column(Modifier.padding(horizontal = 32.dp)) { - Spacer(modifier = Modifier.size(25.dp)) - Text( - text = uiState.title, - style = typo().titleMedium, - color = Color.White, - ) - Column( - modifier = Modifier.padding(vertical = 8.dp), - ) { - Text( - text = stringResource(Res.string.your_playlist), - style = typo().titleSmall, - color = Color.White, + if (isMobilePortrait) { + // Apple Music-style: edge-to-edge artwork + liquid glass buttons. + // Glass buttons MUST be siblings of the backdrop source (not children) + // to avoid render feedback loop / RuntimeShader crash. + val artworkBackdrop = rememberBackdrop() + val backBtnLayer = rememberGraphicsLayer() + val rightGroupLayer = rememberGraphicsLayer() + Box( + modifier = + Modifier + .fillMaxWidth() + .height((screenInfo.hDP / 2).dp), + ) { + // Inner Box — backdrop SOURCE (artwork + overlays only, NO glass) + Box(modifier = Modifier.fillMaxSize().layerBackdrop(artworkBackdrop)) { + AsyncImage( + model = + ImageRequest + .Builder(LocalPlatformContext.current) + .data(uiState.thumbnail) + .diskCachePolicy(CachePolicy.ENABLED) + .memoryCachePolicy(CachePolicy.ENABLED) + .diskCacheKey(uiState.thumbnail) + .memoryCacheKey(uiState.thumbnail) + .crossfade(false) + .build(), + placeholder = painterPlaylistThumbnail(uiState.title, style = typo().labelMedium, 250.dp to 250.dp), + error = painterPlaylistThumbnail(uiState.title, style = typo().labelMedium, 250.dp to 250.dp), + fallback = painterPlaylistThumbnail(uiState.title, style = typo().labelMedium, 250.dp to 250.dp), + contentDescription = null, + contentScale = ContentScale.Crop, + onSuccess = { + bitmap = it.result.image.toImageBitmap() + }, + modifier = Modifier.fillMaxSize(), ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = - stringResource( - Res.string.created_at, - uiState.inLibrary?.format( - LocalDateTime.Format { - hour() - char(':') - minute() - chars(" - ") - day() - char(' ') - monthName( - MonthNames.ENGLISH_FULL, - ) - char(' ') - year() - }, - ) ?: "", - ), - style = typo().bodyMedium, - color = Color(0xC4FFFFFF), + Box( + modifier = + Modifier + .fillMaxWidth() + .height(200.dp) + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + listOf( + Color.Transparent, + Color.Transparent, + mutedPaletteBg.copy(alpha = 0.5f), + mutedPaletteBg, + ), + ), + ), ) + Column( + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = uiState.title, + style = typo().titleLarge, + color = Color.White, + maxLines = 2, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.your_playlist), + style = typo().titleSmall, + color = Color.White, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = + stringResource( + Res.string.created_at, + uiState.inLibrary?.format( + LocalDateTime.Format { + hour() + char(':') + minute() + chars(" - ") + day() + char(' ') + monthName( + MonthNames.ENGLISH_FULL, + ) + char(' ') + year() + }, + ) ?: "", + ), + style = typo().bodyMedium, + color = Color(0xC4FFFFFF), + textAlign = TextAlign.Center, + ) + } } + // Back button + right pill (More only) overlays — liquid glass, siblings of source Row( modifier = - Modifier.fillMaxWidth(), + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + .windowInsetsPadding(WindowInsets.statusBars), verticalAlignment = Alignment.CenterVertically, ) { - Crossfade(isPlaying && playingPlaylistId == LOCAL_PLAYLIST_ID + uiState.id) { isThisPlaying -> - if (isThisPlaying) { - RippleIconButton( - resId = Res.drawable.baseline_pause_circle_24, - fillMaxSize = true, - tint = seed, - modifier = Modifier.size(48.dp), - ) { - sharedViewModel.onUIEvent(UIEvent.PlayPause) - } - } else { - RippleIconButton( - resId = Res.drawable.baseline_play_circle_24, - fillMaxSize = true, - tint = seed, - modifier = Modifier.size(48.dp), - ) { - viewModel.onUIEvent(LocalPlaylistUIEvent.PlayClick) - } + Box( + modifier = + Modifier + .size(48.dp) + .drawBackdropCustomShape( + artworkBackdrop, + backBtnLayer, + 0.5f, + CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + RippleIconButton( + resId = Res.drawable.baseline_arrow_back_ios_new_24, + ) { + navController.navigateUp() } } - Spacer(modifier = Modifier.size(5.dp)) - Crossfade(targetState = downloadState) { - when (it) { - DownloadState.STATE_DOWNLOADED -> { - Box( - modifier = - Modifier - .size(36.dp) - .clip( - CircleShape, - ).clickable { - viewModel.makeToast(runBlocking { getString(Res.string.downloaded) }) - }, - ) { - Icon( - painter = painterResource(Res.drawable.baseline_downloaded), - tint = Color(0xFF00A0CB), - contentDescription = "", - modifier = - Modifier - .size(36.dp) - .padding(2.dp), - ) - } - } - - DownloadState.STATE_DOWNLOADING -> { + Spacer(Modifier.weight(1f)) + Row( + modifier = + Modifier + .height(48.dp) + .drawBackdropCustomShape( + artworkBackdrop, + rightGroupLayer, + 0.5f, + RoundedCornerShape(24.dp), + ), + verticalAlignment = Alignment.CenterVertically, + ) { + // AI Suggest — only when synced with YouTube + if (shouldShowSuggestButton) { + IconButton( + onClick = { + shouldShowSuggestions = !shouldShowSuggestions + }, + ) { Box( modifier = Modifier - .size(36.dp) - .clip( - CircleShape, - ).clickable { - viewModel.makeToast(runBlocking { getString(Res.string.downloading) }) + .size(24.dp) + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + }.drawWithContent { + val width = size.width + val height = size.height + val offsetDraw = width * progressAnimated + val brush = + Brush.linearGradient( + colors = + listOf( + Color(0xFF4C82EF), + Color(0xFFD96570), + ), + start = Offset(offsetDraw, 0f), + end = + Offset( + offsetDraw + width, + height, + ), + ) + with(aiPainter) { + draw(size = Size(width, height)) + } + drawRect( + brush = brush, + blendMode = BlendMode.SrcIn, + ) }, - ) { - Image( - painter = - rememberLottiePainter( - composition = composition, - iterations = Compottie.IterateForever, - ), - contentDescription = "Lottie animation", - modifier = Modifier.fillMaxSize(), - ) - } - } - - else -> { - RippleIconButton( - fillMaxSize = true, - resId = Res.drawable.download_button, - modifier = Modifier.size(36.dp), - ) { - Logger.w("PlaylistScreen", "downloadState: $downloadState") - viewModel.downloadFullPlaylist() - } + ) } } + IconButton( + onClick = onPlaylistMoreClick, + ) { + Icon( + painter = painterResource(Res.drawable.baseline_more_vert_24), + contentDescription = "More", + tint = Color.White, + ) + } } - Spacer(Modifier.weight(1f)) - Spacer(Modifier.size(5.dp)) - AnimatedVisibility(visible = shouldShowSuggestButton) { + } + } + } else { + AsyncImage( + model = + ImageRequest + .Builder(LocalPlatformContext.current) + .data(uiState.thumbnail) + .diskCachePolicy(CachePolicy.ENABLED) + .diskCacheKey(uiState.thumbnail) + .crossfade(550) + .build(), + placeholder = painterPlaylistThumbnail(uiState.title, style = typo().labelMedium, 250.dp to 250.dp), + error = painterPlaylistThumbnail(uiState.title, style = typo().labelMedium, 250.dp to 250.dp), + fallback = painterPlaylistThumbnail(uiState.title, style = typo().labelMedium, 250.dp to 250.dp), + contentDescription = null, + contentScale = ContentScale.FillHeight, + onSuccess = { + bitmap = it.result.image.toImageBitmap() + }, + modifier = + Modifier + .height(250.dp) + .wrapContentWidth() + .align(Alignment.CenterHorizontally) + .clip( + RoundedCornerShape(8.dp), + ), + ) + } + Box( + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(), + ) { + Column(Modifier.padding(horizontal = 32.dp)) { + if (!isMobilePortrait) { + Spacer(modifier = Modifier.size(25.dp)) + Text( + text = uiState.title, + style = typo().titleMedium, + color = Color.White, + ) + Column( + modifier = Modifier.padding(vertical = 8.dp), + ) { + Text( + text = stringResource(Res.string.your_playlist), + style = typo().titleSmall, + color = Color.White, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + stringResource( + Res.string.created_at, + uiState.inLibrary?.format( + LocalDateTime.Format { + hour() + char(':') + minute() + chars(" - ") + day() + char(' ') + monthName( + MonthNames.ENGLISH_FULL, + ) + char(' ') + year() + }, + ) ?: "", + ), + style = typo().bodyMedium, + color = Color(0xC4FFFFFF), + ) + } + } + if (isMobilePortrait) { + // Apple Music-style action row: [Shuffle][Play pill][Download][Suggest] + // centered cluster. Suggest (AI) only shows when synced with YouTube. + val isThisPlaying = + isPlaying && playingPlaylistId == LOCAL_PLAYLIST_ID + uiState.id + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = + Arrangement.spacedBy( + 12.dp, + Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(48.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.12f)) + .clickable { + viewModel.onUIEvent(LocalPlaylistUIEvent.ShuffleClick) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Shuffle, + contentDescription = "Shuffle", + tint = Color.White, + modifier = Modifier.size(22.dp), + ) + } Box( modifier = Modifier - .size(36.dp) + .height(48.dp) + .widthIn(min = 110.dp) .clip(CircleShape) + .background(Color.White) .clickable { - shouldShowSuggestions = !shouldShowSuggestions - }.graphicsLayer { - compositingStrategy = - CompositingStrategy.Offscreen - }.drawWithContent { - val width = size.width - val height = size.height - val offsetDraw = width * progressAnimated - val gradientColors = - listOf( - Color(0xFF4C82EF), - Color(0xFFD96570), + if (isThisPlaying) { + sharedViewModel.onUIEvent(UIEvent.PlayPause) + } else { + viewModel.onUIEvent(LocalPlaylistUIEvent.PlayClick) + } + }.padding(horizontal = 20.dp), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = + if (isThisPlaying) { + Icons.Rounded.Pause + } else { + Icons.Rounded.PlayArrow + }, + contentDescription = null, + tint = Color.Black, + modifier = Modifier.size(22.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = if (isThisPlaying) "Pause" else "Play", + color = Color.Black, + style = typo().labelLarge, + ) + } + } + Box( + modifier = + Modifier + .size(48.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Crossfade(targetState = downloadState) { state -> + when (state) { + DownloadState.STATE_DOWNLOADED -> { + Box( + modifier = + Modifier + .fillMaxSize() + .clickable { + viewModel.makeToast( + runBlocking { getString(Res.string.downloaded) }, + ) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(Res.drawable.baseline_downloaded), + tint = Color(0xFF00A0CB), + contentDescription = "", + modifier = Modifier.size(22.dp), ) - val brush = - Brush.linearGradient( - colors = gradientColors, - start = Offset(offsetDraw, 0f), - end = - Offset( - offsetDraw + width, - height, + } + } + + DownloadState.STATE_DOWNLOADING -> { + Box( + modifier = + Modifier + .fillMaxSize() + .clickable { + viewModel.makeToast( + runBlocking { getString(Res.string.downloading) }, + ) + }, + contentAlignment = Alignment.Center, + ) { + Image( + painter = + rememberLottiePainter( + composition = composition, + iterations = Compottie.IterateForever, ), + contentDescription = "Lottie animation", + modifier = Modifier.size(28.dp), ) - // Destination - with(aiPainter) { - translate( - left = (size.width - width / 1.5f) / 2, - top = (size.height - width / 1.5f) / 2, - ) { - draw( - size = Size(width / 1.5f, width / 1.5f), - ) - } } - // Source - drawRect( - brush = brush, - blendMode = BlendMode.SrcIn, - ) - }, - ) - } - RippleIconButton( - modifier = - Modifier.size(36.dp), - resId = Res.drawable.baseline_shuffle_24, - fillMaxSize = true, - ) { - viewModel.onUIEvent(LocalPlaylistUIEvent.ShuffleClick) + } + + else -> { + Box( + modifier = + Modifier + .fillMaxSize() + .clickable { + Logger.w("PlaylistScreen", "downloadState: $downloadState") + viewModel.downloadFullPlaylist() + }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(Res.drawable.download_button), + tint = Color.White, + contentDescription = "Download", + modifier = Modifier.size(22.dp), + ) + } + } + } + } + } } - Spacer(Modifier.size(5.dp)) - RippleIconButton( + } else { + Row( modifier = - Modifier.size(36.dp), - resId = Res.drawable.baseline_more_vert_24, - fillMaxSize = true, + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, ) { - onPlaylistMoreClick() + Crossfade(isPlaying && playingPlaylistId == LOCAL_PLAYLIST_ID + uiState.id) { isThisPlaying -> + if (isThisPlaying) { + RippleIconButton( + resId = Res.drawable.baseline_pause_circle_24, + fillMaxSize = true, + tint = seed, + modifier = Modifier.size(48.dp), + ) { + sharedViewModel.onUIEvent(UIEvent.PlayPause) + } + } else { + RippleIconButton( + resId = Res.drawable.baseline_play_circle_24, + fillMaxSize = true, + tint = seed, + modifier = Modifier.size(48.dp), + ) { + viewModel.onUIEvent(LocalPlaylistUIEvent.PlayClick) + } + } + } + Spacer(modifier = Modifier.size(5.dp)) + Crossfade(targetState = downloadState) { + when (it) { + DownloadState.STATE_DOWNLOADED -> { + Box( + modifier = + Modifier + .size(36.dp) + .clip( + CircleShape, + ).clickable { + viewModel.makeToast(runBlocking { getString(Res.string.downloaded) }) + }, + ) { + Icon( + painter = painterResource(Res.drawable.baseline_downloaded), + tint = Color(0xFF00A0CB), + contentDescription = "", + modifier = + Modifier + .size(36.dp) + .padding(2.dp), + ) + } + } + + DownloadState.STATE_DOWNLOADING -> { + Box( + modifier = + Modifier + .size(36.dp) + .clip( + CircleShape, + ).clickable { + viewModel.makeToast(runBlocking { getString(Res.string.downloading) }) + }, + ) { + Image( + painter = + rememberLottiePainter( + composition = composition, + iterations = Compottie.IterateForever, + ), + contentDescription = "Lottie animation", + modifier = Modifier.fillMaxSize(), + ) + } + } + + else -> { + RippleIconButton( + fillMaxSize = true, + resId = Res.drawable.download_button, + modifier = Modifier.size(36.dp), + ) { + Logger.w("PlaylistScreen", "downloadState: $downloadState") + viewModel.downloadFullPlaylist() + } + } + } + } + Spacer(Modifier.weight(1f)) + Spacer(Modifier.size(5.dp)) + AnimatedVisibility(visible = shouldShowSuggestButton) { + Box( + modifier = + Modifier + .size(36.dp) + .clip(CircleShape) + .clickable { + shouldShowSuggestions = !shouldShowSuggestions + }.graphicsLayer { + compositingStrategy = + CompositingStrategy.Offscreen + }.drawWithContent { + val width = size.width + val height = size.height + val offsetDraw = width * progressAnimated + val gradientColors = + listOf( + Color(0xFF4C82EF), + Color(0xFFD96570), + ) + val brush = + Brush.linearGradient( + colors = gradientColors, + start = Offset(offsetDraw, 0f), + end = + Offset( + offsetDraw + width, + height, + ), + ) + // Destination + with(aiPainter) { + translate( + left = (size.width - width / 1.5f) / 2, + top = (size.height - width / 1.5f) / 2, + ) { + draw( + size = Size(width / 1.5f, width / 1.5f), + ) + } + } + // Source + drawRect( + brush = brush, + blendMode = BlendMode.SrcIn, + ) + }, + ) + } + RippleIconButton( + modifier = + Modifier.size(36.dp), + resId = Res.drawable.baseline_shuffle_24, + fillMaxSize = true, + ) { + viewModel.onUIEvent(LocalPlaylistUIEvent.ShuffleClick) + } + Spacer(Modifier.size(5.dp)) + RippleIconButton( + modifier = + Modifier.size(36.dp), + resId = Res.drawable.baseline_more_vert_24, + fillMaxSize = true, + ) { + onPlaylistMoreClick() + } } } Text( @@ -813,23 +1221,25 @@ fun LocalPlaylistScreen( } Column { Row(horizontalArrangement = Arrangement.SpaceBetween) { - ElevatedButton( - contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp), - modifier = - Modifier - .defaultMinSize(minWidth = 1.dp, minHeight = 1.dp), - onClick = { - sortBottomSheetShow = true - }, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { + if (isMobilePortrait) { + // Match the Apple Music action buttons: flat white@12% pill, white content. + Row( + modifier = + Modifier + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.12f)) + .clickable { + sortBottomSheetShow = true + }.padding(vertical = 8.dp, horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Icon( imageVector = Icons.AutoMirrored.Sharp.Sort, contentDescription = "Sort playlist", tint = Color.White, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(20.dp), ) - Spacer(modifier = Modifier.size(4.dp)) + Spacer(modifier = Modifier.size(6.dp)) Text( text = stringResource(Res.string.sort_by) + ": " + @@ -837,9 +1247,38 @@ fun LocalPlaylistScreen( uiState.filterState.displayNameRes(), ), style = typo().bodySmall, - color = Color.Gray, + color = Color.White, ) } + } else { + ElevatedButton( + contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp), + modifier = + Modifier + .defaultMinSize(minWidth = 1.dp, minHeight = 1.dp), + onClick = { + sortBottomSheetShow = true + }, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.AutoMirrored.Sharp.Sort, + contentDescription = "Sort playlist", + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.size(4.dp)) + Text( + text = + stringResource(Res.string.sort_by) + ": " + + stringResource( + uiState.filterState.displayNameRes(), + ), + style = typo().bodySmall, + color = Color.Gray, + ) + } + } } Spacer(Modifier.weight(1f)) AnimatedVisibility( @@ -861,7 +1300,7 @@ fun LocalPlaylistScreen( "Change order" }, style = typo().bodySmall, - color = Color.Gray, + color = if (isMobilePortrait) Color.White else Color.Gray, ) } } @@ -879,7 +1318,7 @@ fun LocalPlaylistScreen( }) { index -> val item = trackPagingItems[index]?.first if (item != null) { - val content = @Composable { + val content = @Composable { mod: Modifier -> if (playingTrack?.videoId == item.videoId && isPlaying) { SongFullWidthItems( isPlaying = true, @@ -895,7 +1334,7 @@ fun LocalPlaylistScreen( arrayListOf(item.toTrack()), ) }, - modifier = Modifier.animateItem(), + modifier = mod, ) } else { SongFullWidthItems( @@ -912,7 +1351,7 @@ fun LocalPlaylistScreen( arrayListOf(item.toTrack()), ) }, - modifier = Modifier.animateItem(), + modifier = mod, ) } } @@ -922,10 +1361,19 @@ fun LocalPlaylistScreen( index + 1, Modifier.animateItem(), ) { - content() + content(Modifier) } } else { - content() + Column(modifier = Modifier.animateItem()) { + content(Modifier) + if (isMobilePortrait && index < trackPagingItems.itemCount - 1) { + HorizontalDivider( + modifier = Modifier.padding(start = 72.dp, end = 16.dp), + thickness = 0.5.dp, + color = Color.White.copy(alpha = 0.12f), + ) + } + } } } } @@ -1076,7 +1524,17 @@ fun LocalPlaylistScreen( TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, ), - modifier = Modifier.angledGradientBackground(uiState.colors, 90f), + modifier = + if (isMobilePortrait) { + Modifier.hazeEffect(hazeState) { + blurEnabled = true + blurRadius = 24.dp + backgroundColor = mutedPaletteBg + tints = listOf(HazeTint(mutedPaletteBg.copy(alpha = 0.55f))) + } + } else { + Modifier.angledGradientBackground(uiState.colors, 90f) + }, ) } } \ No newline at end of file From 0b72f85b42947e58a408d7577d5f9da93bdb0b6a Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 2 Jun 2026 16:21:28 +0700 Subject: [PATCH 06/26] feat: upgrade version Signed-off-by: maxrave-dev --- core | 2 +- gradle/libs.versions.toml | 27 +++++++++++------------- gradle/wrapper/gradle-wrapper.properties | 4 ++-- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/core b/core index 2e9ddea35..cf36e1269 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 2e9ddea35f659cb2eb6d64848fc76d696a7d5a0f +Subproject commit cf36e1269ee67fa931b7ae18e8a82a2ed1a24066 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 09565df61..99fa57aa8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,10 +3,10 @@ version-name = "1.3.0" version-code = "51" -android = "9.1.1" +android = "9.2.1" kotlin = "2.3.21" serialization = "2.3.21" -ksp = "2.3.6" +ksp = "2.3.9" compose-bom = "2026.05.01" material3-expressive = "1.5.0-alpha20" constraintlayout-compose = "1.1.1" @@ -46,9 +46,9 @@ common = "1.22.0" json = "1.11.0" gemini-kotlin = "4.0.2" slf4j = "1.7.36" -sentry-android = "8.42.0" -sentry-gradle-android = "6.8.1" -sentry-jvm = "8.42.0" +sentry-android = "8.43.0" +sentry-gradle-android = "6.9.0" +sentry-jvm = "8.43.0" newpipe = "v0.26.1" brave-newpipe = "v0.26.2" pipepipe = "30ec94d6d3" @@ -56,7 +56,7 @@ webkit = "1.16.0" kermit = "2.1.0" paging-common = "3.5.0" glance = "1.1.1" -liquid-glass = "2.0.0-alpha03" +liquid-glass = "2.0.0" liquid-glass-shapes = "1.2.0" ffmpeg-kit-audio = "6.0.1" composeHotReload = "1.1.1" @@ -66,14 +66,11 @@ sqlite = "2.6.2" okio = "3.17.0" vlcj = "4.12.1" vlc = "3.0.23" -material3-multiplatform = "1.10.0-alpha05" +material3-multiplatform = "1.12.0-alpha01" adaptive = "1.2.0" -material-multiplatform = "1.11.0" material-icons-multiplatform = "1.7.3" componentsResources = "1.11.0" -foundation = "1.11.0" -runtime = "1.11.0" -compottie = "2.1.0" +compottie = "2.1.0" # 2.2.0 buggy cmptoast = "1.0.71" uri = "0.0.21" buildkonfig = "0.21.2" @@ -94,8 +91,8 @@ compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = compose-material3-lib = { group = "androidx.compose.material3", name = "material3", version.ref = "material3-expressive" } compose-material3 = { group = "org.jetbrains.compose.material3", name = "material3", version.ref = "material3-multiplatform" } compose-material3-adaptive = { group = "org.jetbrains.compose.material3.adaptive", name = "adaptive", version.ref = "adaptive" } -compose-ui = { group = "org.jetbrains.compose.ui", name = "ui", version.ref = "material-multiplatform" } -compose-material-ripple = { group = "org.jetbrains.compose.material", name = "material-ripple", version.ref = "material-multiplatform" } +compose-ui = { group = "org.jetbrains.compose.ui", name = "ui", version.ref = "composeMultiplatform" } +compose-material-ripple = { group = "org.jetbrains.compose.material", name = "material-ripple", version.ref = "composeMultiplatform" } compose-material-icons-core = { group = "org.jetbrains.compose.material", name = "material-icons-core", version.ref = "material-icons-multiplatform" } compose-material-icons-extended = { group = "org.jetbrains.compose.material", name = "material-icons-extended", version.ref = "material-icons-multiplatform" } compose-linux-arm64 = { module = "org.jetbrains.compose.desktop:desktop-jvm-linux-arm64", version.ref = "composeMultiplatform" } @@ -105,9 +102,9 @@ compose-macos-x64 = { module = "org.jetbrains.compose.desktop:desktop-jvm-macos- compose-windows-arm64 = { module = "org.jetbrains.compose.desktop:desktop-jvm-windows-arm64", version.ref = "composeMultiplatform" } compose-windows-x64 = { module = "org.jetbrains.compose.desktop:desktop-jvm-windows-x64", version.ref = "composeMultiplatform" } constraintlayout-compose = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version.ref = "constraintlayout-compose" } -foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "foundation" } +foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "componentsResources" } -runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "runtime" } +runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } jetbrains-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f25c0ec20..4f3f70733 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From a22cd99963f6ec1a715e7f9220b04e087260c80c Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 3 Jun 2026 00:20:16 +0700 Subject: [PATCH 07/26] fix(scraper): bump core for YouTube API fixes and curl logger --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index cf36e1269..6b1c0735d 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit cf36e1269ee67fa931b7ae18e8a82a2ed1a24066 +Subproject commit 6b1c0735dbc17d3b02d6e03f85f6682062f25804 From c21be9a10abee1ab8c055edbb840533d88072eaa Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 3 Jun 2026 17:20:48 +0700 Subject: [PATCH 08/26] fix(deps): align lifecycle 2.11.0-beta01 with Compose Multiplatform 1.11.1 --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 99fa57aa8..f46e8bea8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ compose-bom = "2026.05.01" material3-expressive = "1.5.0-alpha20" constraintlayout-compose = "1.1.1" activity-compose = "1.13.0" -lifecycle = "2.10.0" +lifecycle = "2.11.0-beta01" core-ktx = "1.18.0" appcompat = "1.7.1" work = "2.11.2" @@ -60,7 +60,7 @@ liquid-glass = "2.0.0" liquid-glass-shapes = "1.2.0" ffmpeg-kit-audio = "6.0.1" composeHotReload = "1.1.1" -composeMultiplatform = "1.11.0" +composeMultiplatform = "1.11.1" datetime = "0.8.0" sqlite = "2.6.2" okio = "3.17.0" From 4cd133106b0d7405ae5b5fba1d1f2e4638330d1a Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 3 Jun 2026 17:20:48 +0700 Subject: [PATCH 09/26] fix(build): force generateResClass to always for the app module --- composeApp/build.gradle.kts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 1ed5f55d5..8e1721a15 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -28,6 +28,14 @@ plugins { alias(libs.plugins.vlc.setup) } +// composeApp uses the `android.kotlin.multiplatform.library` plugin, so with the +// default `generateResClass = auto` Compose skips generating the `Res` class +// (it treats a KMP *library* module as not owning the public resource class). +// Force it to `always` so `Res` is generated for this app module. +compose.resources { + generateResClass = always +} + kotlin { compilerOptions { freeCompilerArgs.add("-Xwhen-guards") From 0f87894701da3363d0008d3f648bd81a9b52bbf5 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 3 Jun 2026 17:20:48 +0700 Subject: [PATCH 10/26] fix(desktop): run single-instance check before startKoin to avoid DataStore crash --- .../com/maxrave/simpmusic/DesktopApp.kt | 62 ++++++++++++++----- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt index 81a112781..b37c79629 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,6 +38,9 @@ import com.maxrave.simpmusic.viewModel.SharedViewModel import com.maxrave.simpmusic.viewModel.changeLanguageNative import io.sentry.Sentry import io.sentry.SentryLevel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import multiplatform.network.cmptoast.ToastHost @@ -88,6 +92,23 @@ fun runDesktopApp(args: Array = emptyArray()) { args.firstOrNull()?.takeIf { arg -> arg.startsWith("simpmusic://") || arg.startsWith("http://") || arg.startsWith("https://") } + // Single-instance guard — MUST run before startKoin. The DataStore Koin + // singleton is `createdAtStart`, so a second Windows instance would touch + // ~/.simpmusic/settings.preferences_pb and crash with an "Unable to rename + // ...tmp" IOException (#2044) before it ever reached the old in-Compose check. + // Bail out here, before Koin/DataStore initialize. + val isSingleInstance = + SingleInstanceManager.isSingleInstance( + onRestoreRequest = { DesktopRestoreSignal.request() }, + ) + if (!isSingleInstance) { + // Second instance: forward the deep link (if any) to the running instance, + // then exit. Nothing has touched the DataStore file yet. + deepLinkArg?.let { DesktopDeepLinkHandler.writePendingUri(it) } + return + } + + // First instance only: deliver our own deep link (non-macOS passes URI via args). if (!isMacOS) { deepLinkArg?.let { DesktopDeepLinkHandler.onNewUri(it) } } @@ -157,22 +178,16 @@ fun runDesktopApp(args: Array = emptyArray()) { size = DpSize(1500.dp, 860.dp), ) var isVisible by remember { mutableStateOf(true) } - // Single management - val isSingleInstance = - SingleInstanceManager.isSingleInstance( - onRestoreRequest = { - isVisible = true - windowState.isMinimized = false - // Check if a second instance left a deep link URI for us - DesktopDeepLinkHandler.consumePendingUri() - }, - ) - - if (!isSingleInstance) { - // If launched with a deep link URI, write it for the running instance to pick up - deepLinkArg?.let { DesktopDeepLinkHandler.writePendingUri(it) } - exitApplication() - return@application + // The single-instance guard now runs before startKoin (top of + // runDesktopApp). Here we only react to a restore request raised when a + // second instance launches: bring the window back to the foreground and + // consume any deep link the second instance forwarded. + LaunchedEffect(Unit) { + DesktopRestoreSignal.requests.collect { + isVisible = true + windowState.isMinimized = false + DesktopDeepLinkHandler.consumePendingUri() + } } val openAppString = stringResource(Res.string.open_app) val quitAppString = stringResource(Res.string.quit_app) @@ -335,4 +350,19 @@ fun runDesktopApp(args: Array = emptyArray()) { ) } } +} + +/** + * Bridges a restore request from the single-instance guard (which runs outside + * Compose, at the top of [runDesktopApp]) into the running window's composition. + * The guard calls [request] when a second instance launches; the window collects + * [requests] to bring itself back to the foreground and pick up any deep link. + */ +private object DesktopRestoreSignal { + private val _requests = MutableSharedFlow(extraBufferCapacity = 1) + val requests: SharedFlow = _requests.asSharedFlow() + + fun request() { + _requests.tryEmit(Unit) + } } \ No newline at end of file From dc9a9ed61cde56845ffc4e229d33b82356e7876d Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Wed, 3 Jun 2026 17:20:48 +0700 Subject: [PATCH 11/26] feat(windows): reinstall offline installer in place without manual uninstall --- scripts/windows/install.bat | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/windows/install.bat b/scripts/windows/install.bat index 6f25a4f11..9ae1ed843 100644 --- a/scripts/windows/install.bat +++ b/scripts/windows/install.bat @@ -51,9 +51,13 @@ if not defined MSIX ( ) echo. -echo [2/3] Installing MSIX: "%MSIX%" +echo [2/3] Installing/updating MSIX: "%MSIX%" +REM -ForceUpdateFromAnyVersion overwrites an existing install in place (keeps app +REM data) even when the version is identical, so no manual uninstall is needed. +REM If that still fails (e.g. signing publisher changed), fall back to removing +REM the old package and doing a clean install. powershell -NoProfile -ExecutionPolicy Bypass -Command ^ - "try { Add-AppxPackage -Path '%MSIX%' -ForceApplicationShutdown -ErrorAction Stop; exit 0 } catch { Write-Host $_; exit 1 }" + "try { Add-AppxPackage -Path '%MSIX%' -ForceApplicationShutdown -ForceUpdateFromAnyVersion -ErrorAction Stop; exit 0 } catch { Write-Host 'In-place update failed; removing existing SimpMusic and reinstalling...'; Get-AppxPackage -Name 'Simpmusic' | Remove-AppxPackage -ErrorAction SilentlyContinue; try { Add-AppxPackage -Path '%MSIX%' -ForceApplicationShutdown -ErrorAction Stop; exit 0 } catch { Write-Host $_; exit 1 } }" if %errorlevel% neq 0 ( echo. From 904acb4ddc29fcb4c9596a8da1f955ec2c76b5e4 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 4 Jun 2026 10:16:05 +0700 Subject: [PATCH 12/26] feat(scraper): bump core for TIDAL remote credential config --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 6b1c0735d..c470cd006 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 6b1c0735dbc17d3b02d6e03f85f6682062f25804 +Subproject commit c470cd006b237c0feb881a747f89b93781fdb6b8 From 04c62b093a4c4c202f9d5542e398c25fa291b81b Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 4 Jun 2026 16:18:03 +0700 Subject: [PATCH 13/26] feat(ui): liquid glass components + Apple Music-style sliding tab bar (Android) - Reusable Modifier.liquidGlass + LiquidGlassContainer/LiquidGlassIconButton (expect/actual: Android glass, JVM fallback) with a press/hold interaction (subtle scale + radial touch glow) - Apply to the Album/Playlist/LocalPlaylist back button and action pill - Rebuild the Android bottom nav as a liquid sliding tab bar (Home/Library) with a draggable squash/stretch blob indicator and a separate Search FAB; keep collapse-on-scroll, the mini player and background-luminance adaptation - Fix the lens refraction seam on wide pills; make the glass luminance-adaptive dark instead of washing out to white, consistent across mini player, bar and pills --- .../expect/ui/LiquidGlass.android.kt | 21 +- ...quidGlassAppBottomNavigationBar.android.kt | 353 ++++------------ .../component/LiquidGlassContainer.android.kt | 268 ++++++++++++ .../ui/component/LiquidGlassTabBar.android.kt | 382 ++++++++++++++++++ .../ui/component/LiquidGlassContainer.kt | 89 ++++ .../ui/screen/library/LocalPlaylistScreen.kt | 32 +- .../simpmusic/ui/screen/other/AlbumScreen.kt | 38 +- .../ui/screen/other/PlaylistScreen.kt | 32 +- .../ui/component/LiquidGlassContainer.jvm.kt | 18 + 9 files changed, 867 insertions(+), 366 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassTabBar.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.jvm.kt diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/ui/LiquidGlass.android.kt b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/ui/LiquidGlass.android.kt index 5bb5e31b9..22205c055 100644 --- a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/ui/LiquidGlass.android.kt +++ b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/ui/LiquidGlass.android.kt @@ -39,18 +39,10 @@ actual fun Modifier.drawBackdropCustomShape( val l = (luminanceAnimation * 2f - 1f).let { sign(it) * it * it } vibrancy() colorControls( - brightness = - if (l > 0f) { - lerp(0.1f, 0.5f, l) - } else { - lerp(0.1f, -0.2f, -l) - }, - contrast = - if (l > 0f) { - lerp(1f, 0f, l) - } else { - 1f - }, + // Neutral brightness/contrast: the old curve washed the glass out to white on bright + // backgrounds ("đục trắng"). Darkening is done in onDrawSurface (keeps "đục đen"). + brightness = 0.05f, + contrast = 1f, saturation = 1.5f, ) blur( @@ -67,6 +59,9 @@ actual fun Modifier.drawBackdropCustomShape( layer.record { drawBackdrop() } }, shape = { shape }, - onDrawSurface = { drawRect(Color.Black.copy(alpha = 0.1f)) } + onDrawSurface = { + val darken = lerp(0.12f, 0.5f, ((luminanceAnimation - 0.3f) / 0.5f).coerceIn(0f, 1f)) + drawRect(Color.Black.copy(alpha = darken)) + }, ) } \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassAppBottomNavigationBar.android.kt b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassAppBottomNavigationBar.android.kt index 7b7c1138a..eac692d35 100644 --- a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassAppBottomNavigationBar.android.kt +++ b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassAppBottomNavigationBar.android.kt @@ -1,15 +1,10 @@ package com.maxrave.simpmusic.ui.component import android.graphics.Bitmap -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.clickable 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 @@ -22,24 +17,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.outlined.Home -import androidx.compose.material.icons.outlined.Search -import androidx.compose.material.icons.rounded.Home -import androidx.compose.material.icons.rounded.LibraryMusic -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.FloatingToolbarDefaults -import androidx.compose.material3.HorizontalFloatingToolbar -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -50,15 +28,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.layout.layoutId import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintSet @@ -73,28 +46,18 @@ import androidx.navigation.compose.currentBackStackEntryAsState import com.maxrave.domain.data.player.GenericMediaItem import com.maxrave.logger.Logger import com.maxrave.simpmusic.expect.ui.PlatformBackdrop -import com.maxrave.simpmusic.expect.ui.drawBackdropCustomShape -import com.maxrave.simpmusic.extension.greyScale import com.maxrave.simpmusic.ui.navigation.destination.home.HomeDestination import com.maxrave.simpmusic.ui.navigation.destination.library.LibraryDestination import com.maxrave.simpmusic.ui.navigation.destination.search.SearchDestination import com.maxrave.simpmusic.ui.screen.MiniPlayer -import com.maxrave.simpmusic.ui.theme.bottomBarSeedDark -import com.maxrave.simpmusic.ui.theme.customDarkGray -import com.maxrave.simpmusic.ui.theme.customGray -import com.maxrave.simpmusic.ui.theme.transparent -import com.maxrave.simpmusic.ui.theme.typo -import com.maxrave.simpmusic.ui.theme.white import com.maxrave.simpmusic.viewModel.SharedViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext -import org.jetbrains.compose.resources.stringResource import java.nio.IntBuffer import kotlin.reflect.KClass import kotlin.time.Duration.Companion.seconds -import androidx.compose.ui.graphics.lerp as colorLerp private const val TAG = "LiquidGlassAppBottomNavigationBar" @@ -109,16 +72,11 @@ actual fun LiquidGlassAppBottomNavigationBar( onOpenNowPlaying: () -> Unit, reloadDestinationIfNeeded: (KClass<*>) -> Unit ) { - val density = LocalDensity.current val layer = rememberGraphicsLayer() + val toolbarInteraction = rememberGlassInteraction() + val searchFabInteraction = rememberGlassInteraction() val luminanceAnimation = remember { Animatable(0f) } - val customGrayColor by animateColorAsState( - targetValue = colorLerp(customGray, customDarkGray, luminanceAnimation.value * 1.25f), - animationSpec = tween(1000), - label = "CustomGrayColorAnimation", - ) - LaunchedEffect(layer) { val buffer = IntBuffer.allocate(25) while (isActive) { @@ -165,6 +123,12 @@ actual fun LiquidGlassAppBottomNavigationBar( BottomNavScreen.Search, BottomNavScreen.Library, ) + // Tabs shown in the sliding bar (Apple Music style); Search lives in its own FAB. + val barTabs = + listOf( + BottomNavScreen.Home, + BottomNavScreen.Library, + ) var selectedIndex by rememberSaveable { mutableIntStateOf( when (startDestination) { @@ -175,9 +139,6 @@ actual fun LiquidGlassAppBottomNavigationBar( }, ) } - var previousSelectedIndex by rememberSaveable { - mutableIntStateOf(selectedIndex) - } var isExpanded by rememberSaveable { mutableStateOf(true) } @@ -230,6 +191,29 @@ actual fun LiquidGlassAppBottomNavigationBar( } } + fun selectTab(index: Int) { + val screen = bottomNavScreens.find { it.ordinal == index } ?: return + if (selectedIndex == index) { + if (currentBackStackEntry?.destination?.hierarchy?.any { + it.hasRoute(screen.destination::class) + } == true + ) { + reloadDestinationIfNeeded(screen.destination::class) + } else { + navController.navigate(screen.destination) + } + } else { + selectedIndex = index + navController.navigate(screen.destination) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + } + ConstraintLayout( constraintSet = constraintSet, modifier = @@ -253,241 +237,54 @@ actual fun LiquidGlassAppBottomNavigationBar( Modifier .padding(start = 16.dp) .wrapContentSize() - .layoutId("toolbar"), + .layoutId("toolbar") + .onGloballyPositioned { updateConstraints = true }, ) { - HorizontalFloatingToolbar( - modifier = - Modifier - .drawBackdropCustomShape( - backdrop, - layer, - luminanceAnimation.value, - CircleShape, - ) - .then( - if (!isExpanded) { - Modifier.size(48.dp) - } else { - Modifier.wrapContentSize() - }, - ) - .onGloballyPositioned { - updateConstraints = true - }, - contentPadding = - PaddingValues( - horizontal = if (isExpanded) 4.dp else 0.dp, - ), - colors = - FloatingToolbarDefaults - .standardFloatingToolbarColors() - .copy( - toolbarContainerColor = transparent, - ), - expanded = isExpanded, - trailingContent = { - var buttonSize by remember { mutableStateOf(0.dp to 0.dp) } - bottomNavScreens.filter { it != BottomNavScreen.Search }.forEach { screen -> - Box { - if (selectedIndex == screen.ordinal) { - Box( - modifier = - Modifier - .size(buttonSize.first, buttonSize.second) - .clip(CircleShape) - .blur(8.dp), - ) - } - Button( - modifier = - Modifier.onGloballyPositioned { - if (selectedIndex == screen.ordinal) { - buttonSize = with(density) { it.size.width.toDp() to it.size.height.toDp() } - } - }, - onClick = { - if (selectedIndex == screen.ordinal) { - if (currentBackStackEntry?.destination?.hierarchy?.any { - it.hasRoute(screen.destination::class) - } == true - ) { - reloadDestinationIfNeeded( - screen.destination::class, - ) - } else { - navController.navigate(screen.destination) - } - } else { - previousSelectedIndex = selectedIndex - selectedIndex = screen.ordinal - navController.navigate(screen.destination) { - popUpTo(navController.graph.startDestinationId) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - } - }, - shape = CircleShape, - colors = - ButtonDefaults - .buttonColors() - .copy( - disabledContainerColor = transparent, - containerColor = - if (selectedIndex == screen.ordinal) { - customGrayColor - } else { - transparent - }, - contentColor = - if (selectedIndex == screen.ordinal) { - bottomBarSeedDark - } else { - white - }, - ), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - when (screen) { - BottomNavScreen.Home -> Icons.Rounded.Home - BottomNavScreen.Search -> Icons.Rounded.Search - BottomNavScreen.Library -> Icons.Rounded.LibraryMusic - }, - "", - ) - Text( - stringResource(screen.title), - style = - if (selectedIndex == screen.ordinal) { - typo().bodySmall.copy( - fontWeight = FontWeight.Bold, - ) - } else { - typo().bodySmall.greyScale() - }, - color = - if (selectedIndex == screen.ordinal) { - bottomBarSeedDark - } else { - white - }, - ) - } - } - } - } - }, - ) { - if (!isExpanded) { - val screen = bottomNavScreens.find { screen -> screen.ordinal == selectedIndex } ?: return@HorizontalFloatingToolbar - val previousScreen = - bottomNavScreens.find { screen -> screen.ordinal == previousSelectedIndex } ?: return@HorizontalFloatingToolbar - IconButton( - modifier = - Modifier.size( - FloatingToolbarDefaults.ContainerSize.value.dp, - ), - shape = CircleShape, - onClick = { - if (screen == BottomNavScreen.Search) { - val destination = - when (previousScreen) { - BottomNavScreen.Home -> HomeDestination - BottomNavScreen.Library -> LibraryDestination - else -> HomeDestination - } - selectedIndex = previousSelectedIndex - previousSelectedIndex = BottomNavScreen.Search.ordinal - navController.navigate(destination) { - popUpTo(navController.graph.startDestinationId) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - } else { - isExpanded = true - } - }, - colors = - IconButtonDefaults.iconButtonColors().copy( - contentColor = bottomBarSeedDark, - ), - ) { - Icon( - when (selectedIndex) { - BottomNavScreen.Home.ordinal -> Icons.Rounded.Home - BottomNavScreen.Search.ordinal -> - when (previousScreen) { - BottomNavScreen.Home -> Icons.Rounded.Home - BottomNavScreen.Library -> Icons.Rounded.LibraryMusic - else -> Icons.Filled.Search - } - - BottomNavScreen.Library.ordinal -> Icons.Rounded.LibraryMusic - else -> Icons.Outlined.Home - }, - "", - ) - } - } - } - if (isExpanded) { + LiquidGlassTabBar( + tabs = barTabs, + selectedTab = barTabs.indexOfFirst { it.ordinal == selectedIndex }, + backdrop = backdrop, + layer = layer, + luminance = luminanceAnimation.value, + onTabSelected = { position -> selectTab(barTabs[position].ordinal) }, + ) Spacer(Modifier.size(12.dp)) - } - - val searchColor by animateColorAsState( - targetValue = if (luminanceAnimation.value > 0.6f) Color.Black else Color.White, - label = "MiniPlayerTextColor", - animationSpec = tween(500), - ) - - AnimatedVisibility( - visible = !isInSearchDestination && isExpanded, - enter = - slideInHorizontally( - tween(100), - ) { it / 2 }, - exit = - slideOutHorizontally( - tween(100), - ) { -it / 2 }, - ) { - FloatingActionButton( + // Search lives in its own circular glass FAB (Apple Music style). + Box( + modifier = + Modifier + .size(56.dp) + .drawInteractiveGlass( + backdrop, + layer, + luminanceAnimation.value, + CircleShape, + searchFabInteraction, + ) + .clickable { selectTab(BottomNavScreen.Search.ordinal) }, + contentAlignment = Alignment.Center, + ) { + BottomNavScreen.Search.icon() + } + } else { + val selectedScreen = + bottomNavScreens.find { it.ordinal == selectedIndex } ?: BottomNavScreen.Home + Box( modifier = - Modifier.drawBackdropCustomShape( - backdrop, - layer, - luminanceAnimation.value, - CircleShape, - ), - elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), - onClick = { - previousSelectedIndex = selectedIndex - selectedIndex = BottomNavScreen.Search.ordinal - navController.navigate(BottomNavScreen.Search.destination) { - popUpTo(navController.graph.startDestinationId) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - }, - shape = CircleShape, - containerColor = transparent, - contentColor = transparent, + Modifier + .size(48.dp) + .drawInteractiveGlass( + backdrop, + layer, + luminanceAnimation.value, + CircleShape, + toolbarInteraction, + ) + .clickable { isExpanded = true }, + contentAlignment = Alignment.Center, ) { - Icon( - Icons.Outlined.Search, - "", - tint = searchColor, - ) + selectedScreen.icon() } } } diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.android.kt b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.android.kt new file mode 100644 index 000000000..af7dc0b73 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.android.kt @@ -0,0 +1,268 @@ +package com.maxrave.simpmusic.ui.component + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastFirstOrNull +import androidx.compose.ui.util.lerp +import com.kyant.backdrop.backdrops.LayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.colorControls +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.maxrave.simpmusic.expect.ui.PlatformBackdrop +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.sign + +@Composable +actual fun Modifier.liquidGlass( + backdrop: PlatformBackdrop, + shape: Shape, + interactive: Boolean, +): Modifier { + val layer = rememberGraphicsLayer() + val interaction = rememberGlassInteraction() + return this.drawInteractiveGlass( + backdrop = backdrop, + layer = layer, + luminanceAnimation = 0.5f, + shape = shape, + interaction = if (interactive) interaction else null, + ) +} + +/** + * Press/hold state holder for a single liquid-glass surface. + * + * Project-local, dependency-light reimplementation of Kyant's catalog + * `InteractiveHighlight`: instead of the library-internal AGSL highlight shader + * (whose public helpers are not exposed in backdrop 2.0.0-alpha03) we drive a + * plain [Brush.radialGradient] from [touchPosition] and a spring-animated + * [pressProgress]. The drag detection is observe-only so wrapped buttons keep + * receiving their own clicks. + */ +class GlassInteraction( + private val animationScope: CoroutineScope, +) { + private val pressSpec = spring(dampingRatio = 0.5f, stiffness = 300f, visibilityThreshold = 0.001f) + private val pressAnimation = Animatable(0f, 0.001f) + + /** 0f at rest, animating to 1f while pressed. Read in draw/effect/layer blocks. */ + val pressProgress: Float get() = pressAnimation.value + + /** Local-space touch point used as the centre of the press glow. */ + var touchPosition by mutableStateOf(Offset.Zero) + private set + + suspend fun detectPress(pointer: PointerInputScope) = + with(pointer) { + inspectDragGestures( + onDragStart = { down -> + touchPosition = down.position + animationScope.launch { pressAnimation.animateTo(1f, pressSpec) } + }, + onDragEnd = { animationScope.launch { pressAnimation.animateTo(0f, pressSpec) } }, + onDragCancel = { animationScope.launch { pressAnimation.animateTo(0f, pressSpec) } }, + ) { change, _ -> + touchPosition = change.position + } + } +} + +@Composable +fun rememberGlassInteraction(): GlassInteraction { + val scope = rememberCoroutineScope() + return remember(scope) { GlassInteraction(scope) } +} + +/** + * Draws the liquid-glass effect with the same look as the legacy + * `drawBackdropCustomShape`, plus an optional press response driven by + * [interaction]: the surface scales up a touch, the refraction/blur deepen and a + * radial glow follows the finger. Pass `interaction = null` for a static surface. + * + * [luminanceAnimation] keeps the brightness/contrast curve of the original + * wrapper (the bottom navigation bar animates it; static surfaces pass `0.5f`). + */ +fun Modifier.drawInteractiveGlass( + backdrop: LayerBackdrop, + layer: GraphicsLayer, + luminanceAnimation: Float, + shape: Shape, + interaction: GlassInteraction?, +): Modifier = + this + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + val l = (luminanceAnimation * 2f - 1f).let { sign(it) * it * it } + val press = interaction?.pressProgress ?: 0f + vibrancy() + colorControls( + // Neutral brightness/contrast: the old curve brightened + washed the glass out + // to white on bright backgrounds ("đục trắng"). Darkening is done in onDrawSurface. + brightness = 0.05f, + contrast = 1f, + saturation = 1.5f, + ) + blur( + ( + if (l > 0f) { + lerp(8f.dp.toPx(), 16f.dp.toPx(), l) + } else { + lerp(8f.dp.toPx(), 2f.dp.toPx(), -l) + } + ) + 2f.dp.toPx() * press, + ) + // refractionHeight stays below the stadium inradius (minDimension / 2) so the + // top and bottom refraction never meet at the medial axis — that meeting point on + // a wide pill is what produced the dark horizontal seam. depthEffect is off to + // match the crisp Kyant demo look and avoid the radial discontinuity at the centre. + lens(size.minDimension / 4f + 2f.dp.toPx() * press, size.minDimension / 2f, false) + }, + onDrawBackdrop = { drawBackdrop -> + drawBackdrop() + layer.record { drawBackdrop() } + }, + onDrawSurface = { + // Stay "đục đen": darken more as the background brightens so the glass never washes + // out to white (shared by the bottom bar capsule, search FAB and detail-screen pills). + val darken = lerp(0.12f, 0.5f, ((luminanceAnimation - 0.3f) / 0.5f).coerceIn(0f, 1f)) + drawRect(Color.Black.copy(alpha = darken)) + val press = interaction?.pressProgress ?: 0f + if (press > 0f) { + drawRect( + brush = + Brush.radialGradient( + colors = + listOf( + Color.White.copy(alpha = 0.18f * press), + Color.Transparent, + ), + center = interaction?.touchPosition ?: Offset(size.width / 2f, size.height / 2f), + radius = size.minDimension * 1.2f, + ), + blendMode = BlendMode.Plus, + ) + } + }, + layerBlock = + if (interaction != null) { + { + val scale = lerp(1f, 1.03f, interaction.pressProgress) + scaleX = scale + scaleY = scale + } + } else { + null + }, + ).then( + if (interaction != null) { + Modifier.pointerInput(interaction) { interaction.detectPress(this) } + } else { + Modifier + }, + ) + +/** + * Observe-only drag/press recogniser ported from Kyant's catalog + * `DragGestureInspector`. It never consumes events, so a glass surface can react + * to a press while the buttons it wraps still handle their own taps. + */ +internal suspend fun PointerInputScope.inspectDragGestures( + onDragStart: (down: PointerInputChange) -> Unit = {}, + onDragEnd: (change: PointerInputChange) -> Unit = {}, + onDragCancel: () -> Unit = {}, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, +) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + + val down = awaitFirstDown(requireUnconsumed = false) + + onDragStart(down) + onDrag(down, Offset.Zero) + val upEvent = + drag( + pointerId = down.id, + onDrag = { onDrag(it, it.positionChange()) }, + ) + if (upEvent == null) { + onDragCancel() + } else { + onDragEnd(upEvent) + } + } +} + +private suspend inline fun AwaitPointerEventScope.drag( + pointerId: PointerId, + onDrag: (PointerInputChange) -> Unit, +): PointerInputChange? { + val isPointerUp = currentEvent.changes.fastFirstOrNull { it.id == pointerId }?.pressed != true + if (isPointerUp) { + return null + } + var pointer = pointerId + while (true) { + val change = awaitDragOrUp(pointer) ?: return null + if (change.isConsumed) { + return null + } + if (change.changedToUpIgnoreConsumed()) { + return change + } + onDrag(change) + pointer = change.id + } +} + +private suspend inline fun AwaitPointerEventScope.awaitDragOrUp( + pointerId: PointerId, +): PointerInputChange? { + var pointer = pointerId + while (true) { + val event = awaitPointerEvent() + val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null + if (dragEvent.changedToUpIgnoreConsumed()) { + val otherDown = event.changes.fastFirstOrNull { it.pressed } + if (otherDown == null) { + return dragEvent + } else { + pointer = otherDown.id + } + } else { + val hasDragged = dragEvent.previousPosition != dragEvent.position + if (hasDragged) { + return dragEvent + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassTabBar.android.kt b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassTabBar.android.kt new file mode 100644 index 000000000..a1d40af84 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassTabBar.android.kt @@ -0,0 +1,382 @@ +package com.maxrave.simpmusic.ui.component + +import android.os.SystemClock +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.lerp +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.colorControls +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.shadow.InnerShadow +import com.kyant.backdrop.shadow.Shadow +import com.maxrave.simpmusic.expect.ui.PlatformBackdrop +import com.maxrave.simpmusic.ui.theme.bottomBarSeedDark +import com.maxrave.simpmusic.ui.theme.typo +import com.maxrave.simpmusic.ui.theme.white +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.math.sign + +private val CapsuleShape = RoundedCornerShape(percent = 50) +private val TabWidth = 96.dp +private val BarHeight = 64.dp +private val BlobHeight = 56.dp + +/** + * iOS 26 / Kyant-style liquid sliding tab bar. + * + * Three stacked layers (bottom → top): + * 1. a dark, luminance-adaptive glass **capsule** (the same [drawInteractiveGlass] as the MiniPlayer), + * 2. a **frosted blob** that slides to the selected tab, squashing/stretching with + * drag velocity (`DampedDragAnimation`) — the selection indicator, and + * 3. crisp **icons + labels** on top (so the active label stays sharp, unlike when + * the blob is drawn over it). + * + * Adapted from Kyant's `LiquidBottomTabs`/`DampedDragAnimation` to SimpMusic's + * [BottomNavScreen]s, fixed tab width (to fit the existing ConstraintLayout) and the + * bottom bar's luminance sampling. + * + * @param layer shared graphics layer the capsule records into for luminance sampling. + * @param luminance current sampled luminance (0..1) driving the glass brightness. + * @param onTabSelected fired when the user taps a tab or drag-snaps the blob. + */ +@Composable +fun LiquidGlassTabBar( + tabs: List, + selectedTab: Int, + backdrop: PlatformBackdrop, + layer: GraphicsLayer, + luminance: Float, + modifier: Modifier = Modifier, + onTabSelected: (Int) -> Unit, +) { + val density = LocalDensity.current + val tabsCount = tabs.size + val tabWidthPx = with(density) { TabWidth.toPx() } + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val animationScope = rememberCoroutineScope() + + var currentIndex by remember { mutableIntStateOf(selectedTab.coerceAtLeast(0)) } + // [0] = a real drag happened (vs a pure tap) — keeps the blob from snapping back + // and stealing a tab tap. + val draggedFlag = remember { booleanArrayOf(false) } + + val dampedDrag = + remember(animationScope, tabsCount) { + DampedDragAnimation( + animationScope = animationScope, + initialValue = selectedTab.coerceAtLeast(0).toFloat(), + valueRange = 0f..(tabsCount - 1).toFloat(), + visibilityThreshold = 0.001f, + initialScale = 1f, + pressedScale = 60f / 56f, + onDragStarted = { draggedFlag[0] = false }, + onDragStopped = { + if (draggedFlag[0]) { + val target = targetValue.roundToInt().coerceIn(0, tabsCount - 1) + currentIndex = target + // Always snap the blob to a tab — even if the rounded target is unchanged — + // otherwise `value` settles mid-slide and the blob sticks after release. + animateToValue(target.toFloat()) + } + }, + onDrag = { _, dragAmount -> + if (dragAmount.x != 0f) draggedFlag[0] = true + updateValue( + (targetValue + dragAmount.x / tabWidthPx * if (isLtr) 1f else -1f) + .coerceIn(0f, (tabsCount - 1).toFloat()), + ) + }, + ) + } + + // Keep the blob in sync when selection changes from outside (e.g. back stack). + LaunchedEffect(selectedTab) { + if (selectedTab >= 0 && currentIndex != selectedTab) currentIndex = selectedTab + } + // Drive navigation + blob animation from the internal current index. + LaunchedEffect(dampedDrag) { + snapshotFlow { currentIndex } + .drop(1) + .collectLatest { index -> + dampedDrag.animateToValue(index.toFloat()) + onTabSelected(index) + } + } + + Box( + modifier = modifier.height(BarHeight).width(TabWidth * tabsCount), + contentAlignment = Alignment.CenterStart, + ) { + // 1) Dark frosted glass capsule — the exact same glass the MiniPlayer uses, so the bottom + // bar and the mini player read as one material (drawInteractiveGlass, no white veil). + Box(Modifier.matchParentSize().drawInteractiveGlass(backdrop, layer, luminance, CapsuleShape, null)) + + // 2) Frosted blob selection indicator — slides behind the icons. + Box( + Modifier + .graphicsLayer { + // Per-tab slot start + a symmetric 4dp inset, so the pill keeps equal padding on + // both the first and last tab (the last tab used to sit flush to the edge). + translationX = + (if (isLtr) dampedDrag.value else (tabsCount - 1) - dampedDrag.value) * tabWidthPx + + 4.dp.toPx() + } + .drawBackdrop( + backdrop = backdrop, + shape = { CapsuleShape }, + effects = { + // Luminance only drives the blur here (frosted pill); brightness/contrast stay + // neutral and the "đục đen" darkening is applied in onDrawSurface. + val l = (luminance * 2f - 1f).let { sign(it) * it * it } + val progress = dampedDrag.pressProgress + vibrancy() + colorControls( + brightness = 0.05f, + contrast = 1f, + saturation = 1.5f, + ) + blur( + // Stronger than the bar's blur so the active pill reads as a clearly + // frosted surface (the previous amount was too weak / too close to the bar). + (if (l > 0f) lerp(8f.dp.toPx(), 16f.dp.toPx(), l) else lerp(8f.dp.toPx(), 2f.dp.toPx(), -l)) + + 12f.dp.toPx(), + ) + lens(10f.dp.toPx() * progress, 14f.dp.toPx() * progress, chromaticAberration = true) + }, + highlight = { Highlight.Default.copy(alpha = 0.6f) }, + shadow = { Shadow(radius = 4f.dp, alpha = 0.4f) }, + innerShadow = { + val progress = dampedDrag.pressProgress + InnerShadow(radius = 8f.dp * progress, alpha = progress) + }, + layerBlock = { + scaleX = dampedDrag.scaleX + scaleY = dampedDrag.scaleY + val velocity = dampedDrag.velocity / 10f + scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f) + scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f) + }, + onDrawSurface = { + // Dark "đục đen" active pill — always a touch darker than the bar, scaling + // with the background so it never washes out to white. + val darken = lerp(0.22f, 0.55f, ((luminance - 0.3f) / 0.5f).coerceIn(0f, 1f)) + drawRect(Color.Black.copy(alpha = darken)) + }, + ) + .width(TabWidth - 8.dp) + .height(BlobHeight), + ) + + // 3) Crisp icons + labels on top, carrying the blob drag gesture. + Row( + // No horizontal padding here: tabs tile exactly [0..TabWidth..] so each icon's centre + // lines up with the blob's per-tab slot (the Home icon used to sit 4dp off). + Modifier + .matchParentSize() + .then(dampedDrag.modifier), + verticalAlignment = Alignment.CenterVertically, + ) { + tabs.forEachIndexed { position, screen -> + LiquidGlassTab( + screen = screen, + selected = currentIndex == position, + ) { currentIndex = position } + } + } + } +} + +@Composable +private fun LiquidGlassTab( + screen: BottomNavScreen, + selected: Boolean, + onClick: () -> Unit, +) { + val color = if (selected) bottomBarSeedDark else white + Column( + Modifier + .width(TabWidth) + .fillMaxHeight() + .clip(CapsuleShape) + .clickable( + interactionSource = null, + indication = null, + role = Role.Tab, + onClick = onClick, + ), + verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CompositionLocalProvider(LocalContentColor provides color) { + screen.icon() + Text( + text = stringResource(screen.title), + style = typo().bodySmall, + color = color, + maxLines = 1, + ) + } + } +} + +/** + * Spring-damped 1-D drag animation: tracks a continuous [value] across the tab + * range, the drag [velocity] (for squash/stretch), a [pressProgress] (rest → lifted + * glass) and decoupled [scaleX]/[scaleY] springs. Project-local port of Kyant's + * catalog `DampedDragAnimation` (uses [withFrameNanos] for `awaitFrame` and + * [SystemClock] for velocity timestamps). + */ +class DampedDragAnimation( + private val animationScope: CoroutineScope, + val initialValue: Float, + val valueRange: ClosedRange, + val visibilityThreshold: Float, + val initialScale: Float, + val pressedScale: Float, + val onDragStarted: DampedDragAnimation.(position: Offset) -> Unit, + val onDragStopped: DampedDragAnimation.() -> Unit, + val onDrag: DampedDragAnimation.(size: IntSize, dragAmount: Offset) -> Unit, +) { + private val valueAnimationSpec = spring(1f, 1000f, visibilityThreshold) + private val velocityAnimationSpec = spring(0.5f, 300f, visibilityThreshold * 10f) + private val pressProgressAnimationSpec = spring(1f, 1000f, 0.001f) + private val scaleXAnimationSpec = spring(0.6f, 250f, 0.001f) + private val scaleYAnimationSpec = spring(0.7f, 250f, 0.001f) + + private val valueAnimation = Animatable(initialValue, visibilityThreshold) + private val velocityAnimation = Animatable(0f, 5f) + private val pressProgressAnimation = Animatable(0f, 0.001f) + private val scaleXAnimation = Animatable(initialScale, 0.001f) + private val scaleYAnimation = Animatable(initialScale, 0.001f) + + private val mutatorMutex = MutatorMutex() + private val velocityTracker = VelocityTracker() + + val value: Float get() = valueAnimation.value + val targetValue: Float get() = valueAnimation.targetValue + val pressProgress: Float get() = pressProgressAnimation.value + val scaleX: Float get() = scaleXAnimation.value + val scaleY: Float get() = scaleYAnimation.value + val velocity: Float get() = velocityAnimation.value + + val modifier: Modifier = + Modifier.pointerInput(Unit) { + inspectDragGestures( + onDragStart = { down -> + onDragStarted(down.position) + press() + }, + onDragEnd = { + onDragStopped() + release() + }, + onDragCancel = { + onDragStopped() + release() + }, + ) { _, dragAmount -> + onDrag(size, dragAmount) + } + } + + fun press() { + velocityTracker.resetTracking() + animationScope.launch { + launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) } + launch { scaleXAnimation.animateTo(pressedScale, scaleXAnimationSpec) } + launch { scaleYAnimation.animateTo(pressedScale, scaleYAnimationSpec) } + } + } + + fun release() { + animationScope.launch { + withFrameNanos {} + if (value != targetValue) { + val threshold = (valueRange.endInclusive - valueRange.start) * 0.025f + snapshotFlow { valueAnimation.value } + .filter { abs(it - valueAnimation.targetValue) < threshold } + .first() + } + launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) } + launch { scaleXAnimation.animateTo(initialScale, scaleXAnimationSpec) } + launch { scaleYAnimation.animateTo(initialScale, scaleYAnimationSpec) } + } + } + + fun updateValue(value: Float) { + val target = value.coerceIn(valueRange.start, valueRange.endInclusive) + animationScope.launch { + valueAnimation.animateTo(target, valueAnimationSpec) { updateVelocity() } + } + } + + fun animateToValue(value: Float) { + animationScope.launch { + mutatorMutex.mutate { + press() + val target = value.coerceIn(valueRange.start, valueRange.endInclusive) + launch { valueAnimation.animateTo(target, valueAnimationSpec) } + if (velocity != 0f) { + launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) } + } + release() + } + } + } + + private fun updateVelocity() { + velocityTracker.addPosition(SystemClock.uptimeMillis(), Offset(value, 0f)) + val targetVelocity = + velocityTracker.calculateVelocity().x / (valueRange.endInclusive - valueRange.start) + animationScope.launch { velocityAnimation.animateTo(targetVelocity, velocityAnimationSpec) } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.kt new file mode 100644 index 000000000..c25604416 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.kt @@ -0,0 +1,89 @@ +package com.maxrave.simpmusic.ui.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +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 com.maxrave.simpmusic.expect.ui.PlatformBackdrop +import org.jetbrains.compose.resources.DrawableResource + +/** + * Applies the SimpMusic liquid-glass effect to any element. + * + * This is the single platform-specific primitive behind the glass buttons that + * used to be hand-wired inline on every screen. It encapsulates the per-surface + * [androidx.compose.ui.graphics.layer.GraphicsLayer], the Kyant `drawBackdrop` + * effect stack and — on Android — the press/hold "liquid" interaction (a slight + * scale-up, deeper refraction and a radial glow that follows the finger, springing + * back on release). The press gesture is observe-only, so wrapped click handlers + * keep working. + * + * The element MUST be a sibling of the backdrop source (the box carrying + * [com.maxrave.simpmusic.expect.ui.layerBackdrop]); nesting it inside the source + * creates a render-feedback loop that crashes the RuntimeShader. + * + * On desktop (JVM) the underlying backdrop modifiers are no-ops, so this only + * clips to [shape] and the look degrades gracefully. + * + * @param interactive Android only — set false for a static glass surface. + */ +@Composable +expect fun Modifier.liquidGlass( + backdrop: PlatformBackdrop, + shape: Shape = CircleShape, + interactive: Boolean = true, +): Modifier + +/** + * A liquid-glass surface wrapping arbitrary [content] (e.g. a pill of icon + * buttons). Thin convenience over [liquidGlass]; pure common code. + */ +@Composable +fun LiquidGlassContainer( + backdrop: PlatformBackdrop, + modifier: Modifier = Modifier, + shape: Shape = CircleShape, + interactive: Boolean = true, + contentAlignment: Alignment = Alignment.Center, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier.liquidGlass(backdrop, shape, interactive), + contentAlignment = contentAlignment, + content = content, + ) +} + +/** + * Convenience wrapper around [LiquidGlassContainer] for the common single-icon + * case (e.g. the circular back button shared by the detail screens). + */ +@Composable +fun LiquidGlassIconButton( + backdrop: PlatformBackdrop, + resId: DrawableResource, + modifier: Modifier = Modifier.size(48.dp), + shape: Shape = CircleShape, + tint: Color = Color.White, + interactive: Boolean = true, + onClick: () -> Unit, +) { + LiquidGlassContainer( + backdrop = backdrop, + modifier = modifier, + shape = shape, + interactive = interactive, + ) { + RippleIconButton( + resId = resId, + tint = tint, + onClick = onClick, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LocalPlaylistScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LocalPlaylistScreen.kt index e7a653b32..10a2c8bb8 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LocalPlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/library/LocalPlaylistScreen.kt @@ -87,7 +87,6 @@ import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.graphics.rememberGraphicsLayer import com.maxrave.simpmusic.expect.ui.toImageBitmap import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.graphicsLayer @@ -116,7 +115,6 @@ import com.maxrave.domain.utils.FilterState import com.maxrave.domain.utils.toTrack import com.maxrave.logger.Logger import com.maxrave.simpmusic.Platform -import com.maxrave.simpmusic.expect.ui.drawBackdropCustomShape import com.maxrave.simpmusic.expect.ui.layerBackdrop import com.maxrave.simpmusic.expect.ui.rememberBackdrop import com.maxrave.simpmusic.extension.angledGradientBackground @@ -130,7 +128,9 @@ import com.maxrave.simpmusic.ui.component.EndOfPage import com.maxrave.simpmusic.ui.component.LoadingDialog import com.maxrave.simpmusic.ui.component.LocalPlaylistBottomSheet import com.maxrave.simpmusic.ui.component.NowPlayingBottomSheet +import com.maxrave.simpmusic.ui.component.LiquidGlassIconButton import com.maxrave.simpmusic.ui.component.RippleIconButton +import com.maxrave.simpmusic.ui.component.liquidGlass import com.maxrave.simpmusic.ui.component.SongFullWidthItems import com.maxrave.simpmusic.ui.component.SortPlaylistBottomSheet import com.maxrave.simpmusic.ui.component.SuggestItems @@ -560,8 +560,6 @@ fun LocalPlaylistScreen( // Glass buttons MUST be siblings of the backdrop source (not children) // to avoid render feedback loop / RuntimeShader crash. val artworkBackdrop = rememberBackdrop() - val backBtnLayer = rememberGraphicsLayer() - val rightGroupLayer = rememberGraphicsLayer() Box( modifier = Modifier @@ -668,35 +666,21 @@ fun LocalPlaylistScreen( .windowInsetsPadding(WindowInsets.statusBars), verticalAlignment = Alignment.CenterVertically, ) { - Box( + LiquidGlassIconButton( + backdrop = artworkBackdrop, + resId = Res.drawable.baseline_arrow_back_ios_new_24, modifier = Modifier - .size(48.dp) - .drawBackdropCustomShape( - artworkBackdrop, - backBtnLayer, - 0.5f, - CircleShape, - ), - contentAlignment = Alignment.Center, + .size(48.dp), ) { - RippleIconButton( - resId = Res.drawable.baseline_arrow_back_ios_new_24, - ) { - navController.navigateUp() - } + navController.navigateUp() } Spacer(Modifier.weight(1f)) Row( modifier = Modifier .height(48.dp) - .drawBackdropCustomShape( - artworkBackdrop, - rightGroupLayer, - 0.5f, - RoundedCornerShape(24.dp), - ), + .liquidGlass(artworkBackdrop, RoundedCornerShape(24.dp)), verticalAlignment = Alignment.CenterVertically, ) { // AI Suggest — only when synced with YouTube diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/other/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/other/AlbumScreen.kt index 395f230f1..4b8e228cb 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/other/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/other/AlbumScreen.kt @@ -64,7 +64,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign @@ -82,7 +81,6 @@ import com.maxrave.domain.data.entities.DownloadState import com.maxrave.domain.data.model.browse.album.Track import com.maxrave.domain.utils.toSongEntity import com.maxrave.simpmusic.Platform -import com.maxrave.simpmusic.expect.ui.drawBackdropCustomShape import com.maxrave.simpmusic.expect.ui.layerBackdrop import com.maxrave.simpmusic.expect.ui.rememberBackdrop import com.maxrave.simpmusic.expect.ui.toImageBitmap @@ -97,7 +95,9 @@ import com.maxrave.simpmusic.ui.component.HeartCheckBox import com.maxrave.simpmusic.ui.component.HomeItemContentPlaylist import com.maxrave.simpmusic.ui.component.NowPlayingBottomSheet import com.maxrave.simpmusic.ui.component.PlaylistBottomSheet +import com.maxrave.simpmusic.ui.component.LiquidGlassIconButton import com.maxrave.simpmusic.ui.component.RippleIconButton +import com.maxrave.simpmusic.ui.component.liquidGlass import com.maxrave.simpmusic.ui.component.SongFullWidthItems import com.maxrave.simpmusic.ui.navigation.destination.list.AlbumDestination import com.maxrave.simpmusic.ui.navigation.destination.list.ArtistDestination @@ -328,8 +328,6 @@ fun AlbumScreen( // Glass buttons MUST be siblings of the backdrop source (not children) // to avoid render feedback loop / RuntimeShader crash. val artworkBackdrop = rememberBackdrop() - val backBtnLayer = rememberGraphicsLayer() - val rightGroupLayer = rememberGraphicsLayer() Box( modifier = Modifier @@ -427,26 +425,17 @@ fun AlbumScreen( } } // Back button — liquid glass effect (Kyant backdrop) - Box( + LiquidGlassIconButton( + backdrop = artworkBackdrop, + resId = Res.drawable.baseline_arrow_back_ios_new_24, modifier = Modifier - .align(Alignment.TopStart) - .padding(12.dp) - .windowInsetsPadding(WindowInsets.statusBars) - .size(48.dp) - .drawBackdropCustomShape( - artworkBackdrop, - backBtnLayer, - 0.5f, - CircleShape, - ), - contentAlignment = Alignment.Center, + .align(Alignment.TopStart) + .padding(12.dp) + .windowInsetsPadding(WindowInsets.statusBars) + .size(48.dp), ) { - RippleIconButton( - resId = Res.drawable.baseline_arrow_back_ios_new_24, - ) { - navController.navigateUp() - } + navController.navigateUp() } // Heart + More — liquid glass pill Row( @@ -456,12 +445,7 @@ fun AlbumScreen( .padding(12.dp) .windowInsetsPadding(WindowInsets.statusBars) .height(48.dp) - .drawBackdropCustomShape( - artworkBackdrop, - rightGroupLayer, - 0.5f, - RoundedCornerShape(24.dp), - ), + .liquidGlass(artworkBackdrop, RoundedCornerShape(24.dp)), verticalAlignment = Alignment.CenterVertically, ) { Box( diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/other/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/other/PlaylistScreen.kt index 90bcc7c64..ac823546a 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/other/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/other/PlaylistScreen.kt @@ -78,7 +78,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign @@ -98,7 +97,6 @@ import com.maxrave.domain.data.model.browse.album.Track import com.maxrave.domain.utils.toSongEntity import com.maxrave.logger.Logger import com.maxrave.simpmusic.Platform -import com.maxrave.simpmusic.expect.ui.drawBackdropCustomShape import com.maxrave.simpmusic.expect.ui.layerBackdrop import com.maxrave.simpmusic.expect.ui.rememberBackdrop import com.maxrave.simpmusic.expect.ui.toImageBitmap @@ -114,7 +112,9 @@ import com.maxrave.simpmusic.ui.component.HeartCheckBox import com.maxrave.simpmusic.ui.component.LoadingDialog import com.maxrave.simpmusic.ui.component.NowPlayingBottomSheet import com.maxrave.simpmusic.ui.component.PlaylistBottomSheet +import com.maxrave.simpmusic.ui.component.LiquidGlassIconButton import com.maxrave.simpmusic.ui.component.RippleIconButton +import com.maxrave.simpmusic.ui.component.liquidGlass import com.maxrave.simpmusic.ui.component.SongFullWidthItems import com.maxrave.simpmusic.ui.navigation.destination.list.ArtistDestination import com.maxrave.simpmusic.ui.theme.md_theme_dark_background @@ -471,8 +471,6 @@ fun PlaylistScreen( // Glass buttons MUST be siblings of the backdrop source (not children) // to avoid render feedback loop / RuntimeShader crash. val artworkBackdrop = rememberBackdrop() - val backBtnLayer = rememberGraphicsLayer() - val rightGroupLayer = rememberGraphicsLayer() Box( modifier = Modifier @@ -587,35 +585,21 @@ fun PlaylistScreen( .windowInsetsPadding(WindowInsets.statusBars), verticalAlignment = Alignment.CenterVertically, ) { - Box( + LiquidGlassIconButton( + backdrop = artworkBackdrop, + resId = Res.drawable.baseline_arrow_back_ios_new_24, modifier = Modifier - .size(48.dp) - .drawBackdropCustomShape( - artworkBackdrop, - backBtnLayer, - 0.5f, - CircleShape, - ), - contentAlignment = Alignment.Center, + .size(48.dp), ) { - RippleIconButton( - resId = Res.drawable.baseline_arrow_back_ios_new_24, - ) { - navController.navigateUp() - } + navController.navigateUp() } Spacer(Modifier.weight(1f)) Row( modifier = Modifier .height(48.dp) - .drawBackdropCustomShape( - artworkBackdrop, - rightGroupLayer, - 0.5f, - RoundedCornerShape(24.dp), - ), + .liquidGlass(artworkBackdrop, RoundedCornerShape(24.dp)), verticalAlignment = Alignment.CenterVertically, ) { if (!data.isRadio) { diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.jvm.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.jvm.kt new file mode 100644 index 000000000..cc7dec4fe --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.jvm.kt @@ -0,0 +1,18 @@ +package com.maxrave.simpmusic.ui.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import com.maxrave.simpmusic.expect.ui.PlatformBackdrop + +/** + * Desktop fallback: the Kyant backdrop modifiers are no-ops on JVM, so the glass + * surface degrades to a plain clip. [interactive] is ignored here. + */ +@Composable +actual fun Modifier.liquidGlass( + backdrop: PlatformBackdrop, + shape: Shape, + interactive: Boolean, +): Modifier = this.clip(shape) From ed5240901c7949f3133e0ea07a1fbef9befc6a16 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 4 Jun 2026 16:27:07 +0700 Subject: [PATCH 14/26] fix(ci): make mac DMG wrapping resilient to the SimpMusic volume race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wrap-mac-dmg wraps two archs (aarch64, amd64) that each mount a volume named "SimpMusic". hdiutil detach releases the device asynchronously, so the second arch's `hdiutil attach` could hit EBUSY (exit 16) — or silently remount as "SimpMusic 1" — once a macos-14 runner image change made the release slower. It only passed before by timing luck. Detach any stale SimpMusic volume first, capture hdiutil's real mount point instead of hardcoding /Volumes/SimpMusic, and retry the attach + force-detach. --- scripts/wrap-mac-dmg.sh | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/scripts/wrap-mac-dmg.sh b/scripts/wrap-mac-dmg.sh index f3bd680ad..ba48b3b1c 100755 --- a/scripts/wrap-mac-dmg.sh +++ b/scripts/wrap-mac-dmg.sh @@ -82,21 +82,47 @@ hdiutil convert "$TMP_DMG" -format UDRW -o "$RW_DMG" >/dev/null rm -f "$TMP_DMG" echo "[wrap-mac-dmg] mount + apply volume icon" -hdiutil attach -nobrowse -quiet "$RW_DMG" -cp "$VOL_ICNS" "/Volumes/SimpMusic/.VolumeIcon.icns" +# Every arch mounts a volume named "SimpMusic". The previous arch's volume may not be +# fully released yet, so a plain `hdiutil attach` on the same name fails with EBUSY (16) +# or silently remounts as "/Volumes/SimpMusic 1". Detach any stale volume first, attach +# with a retry, and use the REAL mount point hdiutil reports instead of a hardcoded path. +detach_stale_volumes() { + for v in /Volumes/SimpMusic /Volumes/SimpMusic\ *; do + [[ -e "$v" ]] && hdiutil detach -force "$v" >/dev/null 2>&1 || true + done +} + +detach_stale_volumes +MOUNT_POINT="" +for attempt in 1 2 3 4 5; do + MOUNT_POINT="$(hdiutil attach -nobrowse -noautoopen "$RW_DMG" 2>/dev/null \ + | grep -Eo '/Volumes/.*$' | tail -1 || true)" + [[ -d "$MOUNT_POINT" ]] && break + echo "[wrap-mac-dmg] attach busy (EBUSY), retry $attempt…" >&2 + detach_stale_volumes + sleep 3 +done +[[ -d "$MOUNT_POINT" ]] || { echo "Could not mount $RW_DMG after retries" >&2; exit 1; } +VOL_NAME="$(basename "$MOUNT_POINT")" + +cp "$VOL_ICNS" "$MOUNT_POINT/.VolumeIcon.icns" # Apple's Finder shows files with `chflags hidden` to users who have # the default "hide dotfiles" setting. For users who enabled "Show all # files" (Cmd+Shift+.), nudge the icon position outside the 1400x800 # viewport so it still doesn't visually clutter the install window. -"$SETFILE" -a V "/Volumes/SimpMusic/.VolumeIcon.icns" +"$SETFILE" -a V "$MOUNT_POINT/.VolumeIcon.icns" osascript -e \ - 'tell application "Finder" to set position of file ".VolumeIcon.icns" of disk "SimpMusic" to {2000, 2000}' \ + "tell application \"Finder\" to set position of file \".VolumeIcon.icns\" of disk \"$VOL_NAME\" to {2000, 2000}" \ >/dev/null 2>&1 || true -chflags hidden "/Volumes/SimpMusic/.VolumeIcon.icns" -"$SETFILE" -a C "/Volumes/SimpMusic" +chflags hidden "$MOUNT_POINT/.VolumeIcon.icns" +"$SETFILE" -a C "$MOUNT_POINT" sync sleep 2 -hdiutil detach -quiet "/Volumes/SimpMusic" +# Force + retry the detach so the next arch starts from a clean slate. +for attempt in 1 2 3; do + hdiutil detach -force "$MOUNT_POINT" >/dev/null 2>&1 && break + sleep 2 +done echo "[wrap-mac-dmg] convert UDRW → UDZO → $OUT_DMG" rm -f "$OUT_DMG" From 04835db4c344346092db4d5f0b05ebbb8ee186a7 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 4 Jun 2026 22:58:51 +0700 Subject: [PATCH 15/26] feat(ui): liquid glass press interaction and bottom-nav reselect fix --- .../component/LiquidGlassContainer.android.kt | 20 ++++++++++++++- .../ui/component/LiquidGlassTabBar.android.kt | 25 ++++++++++++++++--- .../ui/component/LiquidGlassContainer.kt | 18 +++++++++++++ .../maxrave/simpmusic/ui/screen/MiniPlayer.kt | 4 +-- .../ui/component/LiquidGlassContainer.jvm.kt | 10 ++++++++ 5 files changed, 71 insertions(+), 6 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.android.kt b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.android.kt index af7dc0b73..bd5336b4e 100644 --- a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.android.kt +++ b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.android.kt @@ -57,6 +57,24 @@ actual fun Modifier.liquidGlass( ) } +@Composable +actual fun Modifier.liquidGlass( + backdrop: PlatformBackdrop, + layer: GraphicsLayer, + luminanceAnimation: Float, + shape: Shape, + interactive: Boolean, +): Modifier { + val interaction = rememberGlassInteraction() + return this.drawInteractiveGlass( + backdrop = backdrop, + layer = layer, + luminanceAnimation = luminanceAnimation, + shape = shape, + interaction = if (interactive) interaction else null, + ) +} + /** * Press/hold state holder for a single liquid-glass surface. * @@ -176,7 +194,7 @@ fun Modifier.drawInteractiveGlass( layerBlock = if (interaction != null) { { - val scale = lerp(1f, 1.03f, interaction.pressProgress) + val scale = lerp(1f, 1.08f, interaction.pressProgress) scaleX = scale scaleY = scale } diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassTabBar.android.kt b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassTabBar.android.kt index a1d40af84..cd4035cce 100644 --- a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassTabBar.android.kt +++ b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassTabBar.android.kt @@ -103,6 +103,7 @@ fun LiquidGlassTabBar( val tabWidthPx = with(density) { TabWidth.toPx() } val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val animationScope = rememberCoroutineScope() + val barInteraction = rememberGlassInteraction() var currentIndex by remember { mutableIntStateOf(selectedTab.coerceAtLeast(0)) } // [0] = a real drag happened (vs a pure tap) — keeps the blob from snapping back @@ -153,12 +154,22 @@ fun LiquidGlassTabBar( } Box( - modifier = modifier.height(BarHeight).width(TabWidth * tabsCount), + modifier = + modifier + .height(BarHeight) + .width(TabWidth * tabsCount) + // Press detection for the whole capsule lives on the outer Box — it's the common + // ancestor of the glass, blob and tab Row, so it sees the touch on the Initial pass + // before the children. The capsule glass sits underneath the Row and would never get + // the event on its own; it only reads barInteraction to draw the scale + glow. + .pointerInput(barInteraction) { barInteraction.detectPress(this) }, contentAlignment = Alignment.CenterStart, ) { // 1) Dark frosted glass capsule — the exact same glass the MiniPlayer uses, so the bottom // bar and the mini player read as one material (drawInteractiveGlass, no white veil). - Box(Modifier.matchParentSize().drawInteractiveGlass(backdrop, layer, luminance, CapsuleShape, null)) + // barInteraction makes the whole capsule respond to a press (scale + touch glow) like iOS; + // it's observe-only, so tab taps and the blob drag keep working. + Box(Modifier.matchParentSize().drawInteractiveGlass(backdrop, layer, luminance, CapsuleShape, barInteraction)) // 2) Frosted blob selection indicator — slides behind the icons. Box( @@ -229,7 +240,15 @@ fun LiquidGlassTabBar( LiquidGlassTab( screen = screen, selected = currentIndex == position, - ) { currentIndex = position } + ) { + if (position == currentIndex) { + // Re-tapping the active tab: snapshotFlow won't fire (state unchanged), + // so call onTabSelected directly to keep the reload / scroll-to-top behaviour. + onTabSelected(position) + } else { + currentIndex = position + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.kt index c25604416..b1c3cdc5c 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.kt @@ -9,6 +9,7 @@ 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.graphics.layer.GraphicsLayer import androidx.compose.ui.unit.dp import com.maxrave.simpmusic.expect.ui.PlatformBackdrop import org.jetbrains.compose.resources.DrawableResource @@ -40,6 +41,23 @@ expect fun Modifier.liquidGlass( interactive: Boolean = true, ): Modifier +/** + * Overload of [liquidGlass] for surfaces that sample their own background luminance + * (e.g. the MiniPlayer and the bottom bar capsule): the caller owns the [layer] the + * glass records into and drives [luminanceAnimation], so the glass keeps adapting to + * the content behind it — unlike the [liquidGlass] above, which uses a fixed + * mid-luminance. On Android it adds the same press/hold interaction; on desktop it + * degrades to a plain clip. + */ +@Composable +expect fun Modifier.liquidGlass( + backdrop: PlatformBackdrop, + layer: GraphicsLayer, + luminanceAnimation: Float, + shape: Shape = CircleShape, + interactive: Boolean = true, +): Modifier + /** * A liquid-glass surface wrapping arbitrary [content] (e.g. a pill of icon * buttons). Thin convenience over [liquidGlass]; pure common code. diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt index 45c8ef8f0..b31d18e84 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt @@ -100,7 +100,6 @@ import com.maxrave.logger.Logger import com.maxrave.simpmusic.Platform import com.maxrave.simpmusic.expect.toggleMiniPlayer import com.maxrave.simpmusic.expect.ui.PlatformBackdrop -import com.maxrave.simpmusic.expect.ui.drawBackdropCustomShape import com.maxrave.simpmusic.expect.ui.toImageBitmap import com.maxrave.simpmusic.extension.formatDuration import com.maxrave.simpmusic.extension.getColorFromPalette @@ -110,6 +109,7 @@ import com.maxrave.simpmusic.ui.component.ExplicitBadge import com.maxrave.simpmusic.ui.component.HeartCheckBox import com.maxrave.simpmusic.ui.component.PlayPauseButton import com.maxrave.simpmusic.ui.component.PlayerControlLayout +import com.maxrave.simpmusic.ui.component.liquidGlass import com.maxrave.simpmusic.ui.theme.transparent import com.maxrave.simpmusic.ui.theme.typo import com.maxrave.simpmusic.viewModel.SharedViewModel @@ -290,7 +290,7 @@ fun MiniPlayer( modifier .then( if (isLiquidGlassEnabled == DataStoreManager.TRUE) { - Modifier.drawBackdropCustomShape(backdrop, layer, luminanceAnimation.value, RoundedCornerShape(16.dp)) + Modifier.liquidGlass(backdrop, layer, luminanceAnimation.value, RoundedCornerShape(16.dp)) } else { Modifier }, diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.jvm.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.jvm.kt index cc7dec4fe..f20270c18 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.jvm.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.layer.GraphicsLayer import com.maxrave.simpmusic.expect.ui.PlatformBackdrop /** @@ -16,3 +17,12 @@ actual fun Modifier.liquidGlass( shape: Shape, interactive: Boolean, ): Modifier = this.clip(shape) + +@Composable +actual fun Modifier.liquidGlass( + backdrop: PlatformBackdrop, + layer: GraphicsLayer, + luminanceAnimation: Float, + shape: Shape, + interactive: Boolean, +): Modifier = this.clip(shape) From 196040566b1dd1d3a96bde75b62a674966a57353 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Thu, 4 Jun 2026 22:58:51 +0700 Subject: [PATCH 16/26] feat: show podcast shows on home and open podcast screen --- .../com/maxrave/simpmusic/ui/component/AdapterItems.kt | 9 +++++++++ core | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/AdapterItems.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/AdapterItems.kt index 01b0f682d..686de82d8 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/AdapterItems.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/AdapterItems.kt @@ -86,6 +86,7 @@ import com.maxrave.simpmusic.getPlatform import com.maxrave.simpmusic.ui.navigation.destination.list.AlbumDestination import com.maxrave.simpmusic.ui.navigation.destination.list.ArtistDestination import com.maxrave.simpmusic.ui.navigation.destination.list.PlaylistDestination +import com.maxrave.simpmusic.ui.navigation.destination.list.PodcastDestination import com.maxrave.simpmusic.ui.theme.typo import com.maxrave.simpmusic.viewModel.HomeViewModel import org.jetbrains.compose.resources.painterResource @@ -219,6 +220,14 @@ fun HomeItem( ), ) }, data = temp) + } else if (browseId.startsWith("MPSP")) { + HomeItemContentPlaylist(onClick = { + navController.navigate( + PodcastDestination( + podcastId = browseId, + ), + ) + }, data = temp) } else { HomeItemContentPlaylist(onClick = { navController.navigate( diff --git a/core b/core index c470cd006..7e680a8dd 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit c470cd006b237c0feb881a747f89b93781fdb6b8 +Subproject commit 7e680a8ddfded3676d1f49305a8fe2266cda448c From 9c18b58cab536e0d2543e0276eb86485dc589fcd Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Fri, 5 Jun 2026 23:09:04 +0700 Subject: [PATCH 17/26] fix(ui): tune liquid glass press bulge, blur and per-surface scale --- .../simpmusic/ui/component/LiquidGlassContainer.android.kt | 6 +++++- .../simpmusic/ui/component/LiquidGlassTabBar.android.kt | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.android.kt b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.android.kt index bd5336b4e..b1771f551 100644 --- a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.android.kt +++ b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassContainer.android.kt @@ -72,6 +72,9 @@ actual fun Modifier.liquidGlass( luminanceAnimation = luminanceAnimation, shape = shape, interaction = if (interactive) interaction else null, + // MiniPlayer (the only caller of this layer + luminance overload) is a wide surface, so the + // shared 1.12 press scale bulges too hard; use a gentler scale here. + pressedScale = 1.04f, ) } @@ -134,6 +137,7 @@ fun Modifier.drawInteractiveGlass( luminanceAnimation: Float, shape: Shape, interaction: GlassInteraction?, + pressedScale: Float = 1.12f, ): Modifier = this .drawBackdrop( @@ -194,7 +198,7 @@ fun Modifier.drawInteractiveGlass( layerBlock = if (interaction != null) { { - val scale = lerp(1f, 1.08f, interaction.pressProgress) + val scale = lerp(1f, pressedScale, interaction.pressProgress) scaleX = scale scaleY = scale } diff --git a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassTabBar.android.kt b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassTabBar.android.kt index cd4035cce..8ab83c9a0 100644 --- a/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassTabBar.android.kt +++ b/composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassTabBar.android.kt @@ -118,7 +118,9 @@ fun LiquidGlassTabBar( valueRange = 0f..(tabsCount - 1).toFloat(), visibilityThreshold = 0.001f, initialScale = 1f, - pressedScale = 60f / 56f, + // On press the blob inflates from 56dp up to ~76dp — past the 64dp bar height — so it + // visibly bulges out of the capsule row (Apple Music style), then springs back on release. + pressedScale = 76f / 56f, onDragStarted = { draggedFlag[0] = false }, onDragStopped = { if (draggedFlag[0]) { @@ -199,7 +201,7 @@ fun LiquidGlassTabBar( // Stronger than the bar's blur so the active pill reads as a clearly // frosted surface (the previous amount was too weak / too close to the bar). (if (l > 0f) lerp(8f.dp.toPx(), 16f.dp.toPx(), l) else lerp(8f.dp.toPx(), 2f.dp.toPx(), -l)) + - 12f.dp.toPx(), + 20f.dp.toPx(), ) lens(10f.dp.toPx() * progress, 14f.dp.toPx() * progress, chromaticAberration = true) }, From c117c9a7e03fdeca2ad28c507ba08b2aca92c373 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Fri, 5 Jun 2026 23:11:53 +0700 Subject: [PATCH 18/26] chore: disable jetifier --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index f5988a543..40b6a1fa4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects # org.gradle.parallel=true #Fri Nov 08 00:03:25 ICT 2024 -android.enableJetifier=true +android.enableJetifier=false android.nonFinalResIds=false android.nonTransitiveRClass=true android.useAndroidX=true From 827d10564ab7a4270a396d82032910b544fe0c3c Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Fri, 5 Jun 2026 23:11:53 +0700 Subject: [PATCH 19/26] refactor: display chart item name from API --- .../com/maxrave/simpmusic/ui/component/AdapterItems.kt | 6 +++--- core | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/AdapterItems.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/AdapterItems.kt index 686de82d8..9cf7ca1bd 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/AdapterItems.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/AdapterItems.kt @@ -361,7 +361,7 @@ fun HomeItemContentPlaylist( is ChartItem -> { painterPlaylistThumbnail( - "Top 50 Weekly - ${data.country.name}", + data.name, style = typo().bodySmall, thumbSize * 0.9f to thumbSize * 0.9f, ) @@ -383,7 +383,7 @@ fun HomeItemContentPlaylist( is ChartItem -> { painterPlaylistThumbnail( - "Top 50 Weekly - ${data.country.name}", + data.name, style = typo().bodySmall, thumbSize * 0.9f to thumbSize * 0.9f, ) @@ -410,7 +410,7 @@ fun HomeItemContentPlaylist( is com.maxrave.domain.data.model.mood.genre.Content -> data.title.title is com.maxrave.domain.data.model.mood.moodmoments.Content -> data.title is LocalPlaylistEntity -> data.title - is ChartItem -> "Top 50 Weekly - ${data.country.name}" + is ChartItem -> data.name is PlaylistsResult -> data.title is AlbumEntity -> data.title is PlaylistEntity -> data.title diff --git a/core b/core index 7e680a8dd..043df86ae 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 7e680a8ddfded3676d1f49305a8fe2266cda448c +Subproject commit 043df86ae6403800913c7f23acfd8bf6f6507b64 From 53f9762495f76aad11d2ef4d845803ca8e46005f Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 6 Jun 2026 18:37:59 +0700 Subject: [PATCH 20/26] feat(version): bump version Signed-off-by: maxrave-dev --- .../metadata/android/en-US/changelogs/52.txt | 9 ++++++ .../metadata/android/vi-VN/changelogs/52.txt | 9 ++++++ gradle/libs.versions.toml | 32 +++++++++---------- 3 files changed, 33 insertions(+), 17 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/52.txt create mode 100644 fastlane/metadata/android/vi-VN/changelogs/52.txt diff --git a/fastlane/metadata/android/en-US/changelogs/52.txt b/fastlane/metadata/android/en-US/changelogs/52.txt new file mode 100644 index 000000000..d7d46e121 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/52.txt @@ -0,0 +1,9 @@ +- Fix music pausing or stopping on its own between tracks (the background-playback freeze present since 1.0.4) +- New Apple Music-style liquid glass bottom navigation on Android, with a press-to-bulge interaction +- Discover podcast shows on Home and open them in a dedicated podcast screen +- Apple Music-inspired redesign for the Local Playlist screen +- Smoother auto crossfade with refined duration logic +- Fix empty Home and search shelves, and broken playback, after YouTube API changes +- Desktop: auto crossfade support, bundled VLC path fix and a volume boost +- Desktop: fix a startup crash and reinstall Windows updates in place without uninstalling +- Desktop: more reliable macOS DMG packaging diff --git a/fastlane/metadata/android/vi-VN/changelogs/52.txt b/fastlane/metadata/android/vi-VN/changelogs/52.txt new file mode 100644 index 000000000..7bc1fe656 --- /dev/null +++ b/fastlane/metadata/android/vi-VN/changelogs/52.txt @@ -0,0 +1,9 @@ +- Sửa lỗi nhạc tự tạm dừng hoặc dừng hẳn giữa các bài (lỗi treo phát nền tồn tại từ bản 1.0.4) +- Thanh điều hướng dưới kiểu liquid glass giống Apple Music trên Android, có hiệu ứng phồng khi nhấn +- Khám phá các chương trình podcast ngay trên Home và mở trong màn hình podcast riêng +- Thiết kế lại màn hình Playlist ngoại tuyến theo phong cách Apple Music +- Auto crossfade mượt hơn với logic tính thời lượng được tinh chỉnh +- Sửa lỗi Home và tìm kiếm trống, cũng như lỗi phát nhạc, sau khi YouTube đổi API +- Desktop: hỗ trợ auto crossfade, sửa đường dẫn VLC đi kèm và tăng âm lượng +- Desktop: sửa lỗi treo khi khởi động và cài đè bản cập nhật Windows mà không cần gỡ +- Desktop: đóng gói DMG macOS ổn định hơn diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f46e8bea8..c13168028 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,18 +1,18 @@ [versions] # App version -version-name = "1.3.0" -version-code = "51" +version-name = "1.4.0" +version-code = "52" android = "9.2.1" -kotlin = "2.3.21" -serialization = "2.3.21" +kotlin = "2.4.0" +serialization = "2.4.0" ksp = "2.3.9" compose-bom = "2026.05.01" -material3-expressive = "1.5.0-alpha20" +material3-expressive = "1.5.0-alpha21" constraintlayout-compose = "1.1.1" activity-compose = "1.13.0" lifecycle = "2.11.0-beta01" -core-ktx = "1.18.0" +core-ktx = "1.19.0" appcompat = "1.7.1" work = "2.11.2" startup-runtime = "1.2.0" @@ -46,12 +46,12 @@ common = "1.22.0" json = "1.11.0" gemini-kotlin = "4.0.2" slf4j = "1.7.36" -sentry-android = "8.43.0" -sentry-gradle-android = "6.9.0" -sentry-jvm = "8.43.0" +sentry-android = "8.43.1" +sentry-gradle-android = "6.10.0" +sentry-jvm = "8.43.1" newpipe = "v0.26.1" -brave-newpipe = "v0.26.2" -pipepipe = "30ec94d6d3" +brave-newpipe = "v0.26.3" +pipepipe = "a80ec7a9de" webkit = "1.16.0" kermit = "2.1.0" paging-common = "3.5.0" @@ -69,13 +69,13 @@ vlc = "3.0.23" material3-multiplatform = "1.12.0-alpha01" adaptive = "1.2.0" material-icons-multiplatform = "1.7.3" -componentsResources = "1.11.0" -compottie = "2.1.0" # 2.2.0 buggy +componentsResources = "1.11.1" +compottie = "2.2.1" # 2.2.0 buggy cmptoast = "1.0.71" uri = "0.0.21" buildkonfig = "0.21.2" -jna = "5.18.1" -jnaPlatform = "5.18.1" +jna = "5.19.0" +jnaPlatform = "5.19.0" calf = "0.12.0" material = "1.14.0" osdetector = "1.7.3" @@ -83,7 +83,6 @@ tray = "1.3.0" commonsIo = "2.5" packagedeps = "0.2.5" conveyor = "2.0" -vaqua = "13" [libraries] desugaring = { group = "com.android.tools", name = "desugar_jdk_libs_nio", version.ref = "desugaring"} @@ -229,7 +228,6 @@ androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "w androidx-paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging-common" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } -vaqua = { module = "org.violetlib:vaqua", version.ref = "vaqua" } [plugins] android-application = { id = "com.android.application", version.ref = "android" } From c3b9f0e5f433cdddb74ac2ca56c4982524f3c856 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 6 Jun 2026 19:29:09 +0700 Subject: [PATCH 21/26] fix(desktop): pin ProGuard 7.8.1 to read Java 25 bytecode (PipePipeExtractor) --- desktopApp/build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 62a9d4a91..e5ddd0b17 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -233,6 +233,9 @@ compose.desktop { } buildTypes.release.proguard { + // ProGuard 7.7.0 (Compose's default) can't read Java 25 bytecode (class v69) now shipped + // by PipePipeExtractor, which broke :desktopApp:proguardReleaseJars. 7.8.x supports Java 25. + version.set("7.8.1") optimize.set(true) obfuscate.set(true) configurationFiles.from(rootDir.resolve("composeApp/proguard-desktop-rules.pro")) From 4a893807eef3846ac185c3568ef2393dc9829d7c Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 6 Jun 2026 22:01:32 +0700 Subject: [PATCH 22/26] fix(desktop): bundle org.json (20231013) for PipePipe on JVM and keep it in ProGuard --- composeApp/proguard-desktop-rules.pro | 6 +++++- desktopApp/build.gradle.kts | 3 +++ gradle/libs.versions.toml | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/composeApp/proguard-desktop-rules.pro b/composeApp/proguard-desktop-rules.pro index 91adf9b8a..086cd8e80 100644 --- a/composeApp/proguard-desktop-rules.pro +++ b/composeApp/proguard-desktop-rules.pro @@ -308,6 +308,10 @@ -keep class com.grack.nanojson.** { *; } -dontwarn com.grack.nanojson.** +# org.json (JSON-Java) ships with the Android SDK but is added as an explicit JVM-desktop dependency +# because PipePipeExtractor references org.json.* (comment/stream extractors). Keep it. +-keep class org.json.** { *; } + # Brave bundles BitChute / json2java4nanojson model classes referenced by extractor constructors # kept via `-keep class org.schabi.newpipe.extractor.** { *; }`. Without explicit keeps, proguard # can't resolve the descriptor types and aborts with "unresolved reference" warnings. @@ -332,4 +336,4 @@ # signatures (no newer versions available yet). Suppress so ProGuard does not # abort. Runtime risk: NoSuchMethodError if affected code paths are hit. -dontwarn dev.chrisbanes.haze.** --dontwarn io.github.alexzhirkevich.compottie.** +-keep class io.github.alexzhirkevich.compottie.** { *; } diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index e5ddd0b17..d107a4af1 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -92,6 +92,9 @@ kotlin { // Commons-IO drives the custom AppImage packaging task below. implementation(libs.commons.io) + // org.json is bundled in the Android SDK but missing on JVM desktop; PipePipeExtractor + // references org.json.* so it must be provided explicitly here (matches PipePipe's version). + implementation(libs.org.json) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c13168028..ba9928622 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,6 +51,9 @@ sentry-gradle-android = "6.10.0" sentry-jvm = "8.43.1" newpipe = "v0.26.1" brave-newpipe = "v0.26.3" +# org.json ships with the Android SDK but is absent on JVM desktop; PipePipeExtractor references it. +# Match the version PipePipe was compiled against (its POM declared org.json:json:20231013). +org-json = "20231013" pipepipe = "a80ec7a9de" webkit = "1.16.0" kermit = "2.1.0" @@ -173,6 +176,7 @@ gemini-kotlin = { module = "org.simpmusic.gemini-kotlin:openai-client", version. logging = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" } newpipe-extractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipe" } brave-extractor = { module = "com.github.maxrave-dev:BravePipeExtractor", version.ref = "brave-newpipe" } +org-json = { module = "org.json:json", version.ref = "org-json" } pipepipe-extractor = { module = "com.github.maxrave-dev:PipePipeExtractor", version.ref = "pipepipe" } #leak-canary = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary" } kermit-logging = { module = "co.touchlab:kermit", version.ref = "kermit" } From 8b73ee03cafd01ef360024501bec9369651be4c8 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sat, 6 Jun 2026 22:56:40 +0700 Subject: [PATCH 23/26] fix(desktop): restore desktop release build (PipePipe c3139c584d, org.json, ProGuard 7.8.1) --- composeApp/proguard-desktop-rules.pro | 11 +++++++++-- desktopApp/build.gradle.kts | 2 +- gradle/libs.versions.toml | 3 +-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/composeApp/proguard-desktop-rules.pro b/composeApp/proguard-desktop-rules.pro index 086cd8e80..912563add 100644 --- a/composeApp/proguard-desktop-rules.pro +++ b/composeApp/proguard-desktop-rules.pro @@ -308,8 +308,8 @@ -keep class com.grack.nanojson.** { *; } -dontwarn com.grack.nanojson.** -# org.json (JSON-Java) ships with the Android SDK but is added as an explicit JVM-desktop dependency -# because PipePipeExtractor references org.json.* (comment/stream extractors). Keep it. +# org.json (JSON-Java): Android-provided, added as an explicit JVM-desktop dependency because +# PipePipeExtractor references org.json.* (comment/stream extractors). Keep it. -keep class org.json.** { *; } # Brave bundles BitChute / json2java4nanojson model classes referenced by extractor constructors @@ -337,3 +337,10 @@ # abort. Runtime risk: NoSuchMethodError if affected code paths are hit. -dontwarn dev.chrisbanes.haze.** -keep class io.github.alexzhirkevich.compottie.** { *; } +# Compottie's skiko shader helper references org.jetbrains.skia.GradientStyle, removed in the current +# Skiko. Class is gone (can't be kept/added), so suppress the unresolved-reference warning. +-dontwarn io.github.alexzhirkevich.compottie.** + +# JNA references the signature-polymorphic java.lang.invoke.MethodHandle.invoke(...) overloads, which +# ProGuard can't resolve as concrete methods. JNA itself is kept above; suppress these warnings. +-dontwarn com.sun.jna.** diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index d107a4af1..bd2d19111 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -93,7 +93,7 @@ kotlin { // Commons-IO drives the custom AppImage packaging task below. implementation(libs.commons.io) // org.json is bundled in the Android SDK but missing on JVM desktop; PipePipeExtractor - // references org.json.* so it must be provided explicitly here (matches PipePipe's version). + // references org.json.* so it must be provided explicitly here. implementation(libs.org.json) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ba9928622..430704356 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,9 +52,8 @@ sentry-jvm = "8.43.1" newpipe = "v0.26.1" brave-newpipe = "v0.26.3" # org.json ships with the Android SDK but is absent on JVM desktop; PipePipeExtractor references it. -# Match the version PipePipe was compiled against (its POM declared org.json:json:20231013). org-json = "20231013" -pipepipe = "a80ec7a9de" +pipepipe = "c3139c584d" webkit = "1.16.0" kermit = "2.1.0" paging-common = "3.5.0" From f7b4f2905795a3f9de994f33b23863c918a7c244 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sun, 7 Jun 2026 13:38:02 +0700 Subject: [PATCH 24/26] fix(spotify): accept any locale in login success URL --- .../maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt index 71b9ab76d..d0dd892a8 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt @@ -172,9 +172,8 @@ fun SpotifyLoginScreen( } viewModel.setFullSpotifyCookies(cookies) } - val statusWithLocale = Regex("^https://accounts\\.spotify\\.com/[a-z]{2}(-[a-zA-Z]{2})?/status$") - val statusWithoutLocale = Regex("^https://accounts\\.spotify\\.com/status$") - if (statusWithLocale.matches(url) || statusWithoutLocale.matches(url)) { + val statusUrl = Regex("^https://accounts\\.spotify\\.com/(?:[^/]+/)?status$") + if (statusUrl.matches(url)) { cookie .takeIf { it.isNotEmpty() From 079e08c2a35bddb6f6ec0cb96095567c5b42e942 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sun, 7 Jun 2026 13:38:02 +0700 Subject: [PATCH 25/26] chore: update v1.4.0 changelog (52.txt) --- .../metadata/android/en-US/changelogs/52.txt | 10 ++++------ .../metadata/android/vi-VN/changelogs/52.txt | 16 +++++++--------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/fastlane/metadata/android/en-US/changelogs/52.txt b/fastlane/metadata/android/en-US/changelogs/52.txt index d7d46e121..df116bfab 100644 --- a/fastlane/metadata/android/en-US/changelogs/52.txt +++ b/fastlane/metadata/android/en-US/changelogs/52.txt @@ -1,9 +1,7 @@ - Fix music pausing or stopping on its own between tracks (the background-playback freeze present since 1.0.4) -- New Apple Music-style liquid glass bottom navigation on Android, with a press-to-bulge interaction +- New liquid glass bottom navigation on Android, with a press-to-bulge interaction +- New Apple Music-style in Local Playlist screen +- Optimize Spotify login - Discover podcast shows on Home and open them in a dedicated podcast screen -- Apple Music-inspired redesign for the Local Playlist screen -- Smoother auto crossfade with refined duration logic - Fix empty Home and search shelves, and broken playback, after YouTube API changes -- Desktop: auto crossfade support, bundled VLC path fix and a volume boost -- Desktop: fix a startup crash and reinstall Windows updates in place without uninstalling -- Desktop: more reliable macOS DMG packaging +- Desktop: fix a startup crash and reinstall Windows updates in place without uninstalling \ No newline at end of file diff --git a/fastlane/metadata/android/vi-VN/changelogs/52.txt b/fastlane/metadata/android/vi-VN/changelogs/52.txt index 7bc1fe656..416d55ffe 100644 --- a/fastlane/metadata/android/vi-VN/changelogs/52.txt +++ b/fastlane/metadata/android/vi-VN/changelogs/52.txt @@ -1,9 +1,7 @@ -- Sửa lỗi nhạc tự tạm dừng hoặc dừng hẳn giữa các bài (lỗi treo phát nền tồn tại từ bản 1.0.4) -- Thanh điều hướng dưới kiểu liquid glass giống Apple Music trên Android, có hiệu ứng phồng khi nhấn -- Khám phá các chương trình podcast ngay trên Home và mở trong màn hình podcast riêng -- Thiết kế lại màn hình Playlist ngoại tuyến theo phong cách Apple Music -- Auto crossfade mượt hơn với logic tính thời lượng được tinh chỉnh -- Sửa lỗi Home và tìm kiếm trống, cũng như lỗi phát nhạc, sau khi YouTube đổi API -- Desktop: hỗ trợ auto crossfade, sửa đường dẫn VLC đi kèm và tăng âm lượng -- Desktop: sửa lỗi treo khi khởi động và cài đè bản cập nhật Windows mà không cần gỡ -- Desktop: đóng gói DMG macOS ổn định hơn +- Sửa lỗi nhạc tự động tạm dừng hoặc dừng hẳn giữa các bài hát (lỗi đơ phát nhạc nền tồn tại từ phiên bản 1.0.4) +- Thanh điều hướng dưới cùng liquid glass mới trên Android, với hiệu ứng nhấn-phình +- Giao diện mới kiểu Apple Music cho màn hình Local Playlist +- Tối ưu hóa đăng nhập Spotify +- Khám phá các chương trình podcast ở màn hình Home và mở chúng trong màn hình podcast riêng +- Sửa lỗi các kệ (shelf) trống ở Home và tìm kiếm, cùng lỗi phát nhạc, sau khi YouTube thay đổi API +- Desktop: sửa lỗi crash khi khởi động và cài đặt lại bản cập nhật Windows tại chỗ mà không cần gỡ cài đặt \ No newline at end of file From cc185245ca2105356a38f5c5fc9381827b21333a Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Sun, 7 Jun 2026 13:38:02 +0700 Subject: [PATCH 26/26] fix(player): bump core for playback resume on stream retry --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 043df86ae..3ecd15fee 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 043df86ae6403800913c7f23acfd8bf6f6507b64 +Subproject commit 3ecd15fee310e1329501a124a596662655045553