From 786ca759cbd32d37ec24782efb69c41600c22999 Mon Sep 17 00:00:00 2001 From: HrishikeshBhandarkar Date: Sun, 6 Sep 2026 12:11:00 +0530 Subject: [PATCH 01/11] feat(core): update core submodule with Apple Music artwork support --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 044fb60bc..83f8a316f 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 044fb60bc8959fbd9f37eea3094661a072428670 +Subproject commit 83f8a316f745fdc5173301bc8fadce0b993fe6c9 From ad2ee6031308c07c2678ce318b9987ae5d7b612f Mon Sep 17 00:00:00 2001 From: HrishikeshBhandarkar Date: Sun, 6 Sep 2026 12:11:03 +0530 Subject: [PATCH 02/11] feat(player): add Apple Music artwork state and loader in SharedViewModel --- .../simpmusic/viewModel/SharedViewModel.kt | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt index 7d13f13d2..07b861700 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt @@ -266,6 +266,16 @@ class SharedViewModel( Logger.w(tag, "MediaId is ${nowPlaying.mediaItem.mediaId}") getCanvas(nowPlaying.mediaItem.mediaId, (timeline.total / 1000).toInt()) } + if (nowPlaying.mediaItem.isSong() && nowPlayingScreenData.value.amArtworkData == null) { + nowPlaying.songEntity?.let { song -> + getAppleMusicArtwork( + videoId = song.videoId, + title = song.title, + artist = song.artistName?.joinToString(", ") ?: "", + album = song.albumName, + ) + } + } nowPlaying.songEntity?.let { song -> if (nowPlayingScreenData.value.lyricsData == null) { Logger.w(tag, "Get lyrics from format") @@ -367,6 +377,7 @@ class SharedViewModel( canvasData = null, lyricsData = null, songInfoData = null, + amArtworkData = null, playlistName = mediaPlayerHandler.queueData.value ?.data @@ -639,6 +650,34 @@ class SharedViewModel( } } + private var amArtworkJob: Job? = null + + private fun getAppleMusicArtwork( + videoId: String, + title: String, + artist: String, + album: String?, + ) { + Logger.w(tag, "Start getAppleMusicArtwork: $videoId") + amArtworkJob?.cancel() + amArtworkJob = viewModelScope.launch { + if (dataStoreManager.nowPlayingStyle.first() == DataStoreManager.NOW_PLAYING_STYLE_APPLE_MUSIC) { + lyricsCanvasRepository.getAppleMusicSongArtwork(title, artist, album).collect { response -> + val data = response.data + if (response is Resource.Success && data != null && data.hasMotion && nowPlayingState.value?.mediaItem?.mediaId == videoId) { + _nowPlayingScreenData.update { + it.copy(amArtworkData = data) + } + } else if (nowPlayingState.value?.mediaItem?.mediaId == videoId) { + _nowPlayingScreenData.update { + it.copy(amArtworkData = null) + } + } + } + } + } + } + fun getString(key: String): String? = runBlocking { dataStoreManager.getString(key).first() } fun putString( @@ -2120,6 +2159,7 @@ data class NowPlayingScreenData( val lyricsData: LyricsData? = null, val songInfoData: SongInfoEntity? = null, val bitmap: ImageBitmap? = null, + val amArtworkData: com.maxrave.domain.data.model.canvas.AppleMusicArtwork? = null, ) { data class CanvasData( val isVideo: Boolean, @@ -2142,6 +2182,7 @@ data class NowPlayingScreenData( canvasData = null, lyricsData = null, songInfoData = null, + amArtworkData = null, playlistName = "", ) } From 167741a408b13d168a0a0601875f911db44f6b3f Mon Sep 17 00:00:00 2001 From: HrishikeshBhandarkar Date: Sun, 6 Sep 2026 12:11:17 +0530 Subject: [PATCH 03/11] feat(player): support motion video and squircle artwork in Apple Music player view --- .../content/NowPlayingContentAppleMusic.kt | 167 ++++++++++++++---- 1 file changed, 136 insertions(+), 31 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/content/NowPlayingContentAppleMusic.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/content/NowPlayingContentAppleMusic.kt index 2ef9c30b1..c967b16d1 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/content/NowPlayingContentAppleMusic.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/content/NowPlayingContentAppleMusic.kt @@ -53,6 +53,11 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp @@ -166,7 +171,12 @@ fun NowPlayingContentAppleMusic( // rather than an imperative ImageLoader.execute(): the pager's AsyncImage demonstrably loads // this exact url while the execute() call did not, so this uses the path already proven to // work rather than a second one that has to be kept working. - var backdropUrl by remember(state.screenData.thumbnailURL) { mutableStateOf(state.screenData.thumbnailURL) } + var backdropUrl by remember(state.screenData.thumbnailURL, state.screenData.amArtworkData) { + val amData = state.screenData.amArtworkData + val amUrl = if (amData?.hasMotion == true) amData.staticArtworkUrl else null + val url = if (!amUrl.isNullOrBlank()) amUrl else state.screenData.thumbnailURL + mutableStateOf(url) + } val paletteColor = state.startColor.value val seedColor = if (paletteColor == Color.Black) seed else paletteColor @@ -213,30 +223,22 @@ fun NowPlayingContentAppleMusic( Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { Box(modifier = Modifier.matchParentSize().layerBackdrop(panelBackdrop)) { // Apple frosts the COVER ART into the page background — the colour and the soft blotches - // of the artwork stay visible through it. A flat tinted gradient, which is what this used - // to be, gets the hue right and loses everything else: the page reads as a solid colour - // swatch rather than as the record it belongs to. - // - // Loaded straight from the url by AsyncImage rather than through the screen state's - // decoded bitmap. The background IS an image, so there is no reason to route it through a - // bitmap someone else has to remember to fill in — which is exactly what broke: the only - // thing feeding that bitmap was the artwork pager inside MAIN, so on QUEUE or LYRICS a - // track change left it null and the page fell back to a bare gradient. - // - // The palette still needs a bitmap, and it comes off this same load. One source, so the - // frosted art and the tint over it cannot end up belonging to different songs. - // - // The heavy blur radius is safe because the whole style is gated behind Android 12 for - // exactly this reason (isLyricsBlurSupported), and Crop + fillMaxSize means the artwork is - // scaled far past its own resolution — at this blur that costs nothing visually. - if (!backdropUrl.isNullOrBlank()) { + // of the artwork stay visible through it. + // When lyrics view is open, the background blur strictly follows the static album art sequence. + val blurSourceUrl = if (viewState == AppleMusicView.LYRICS) { + state.screenData.thumbnailURL + } else { + backdropUrl + } + + if (!blurSourceUrl.isNullOrBlank()) { AsyncImage( model = ImageRequest .Builder(LocalPlatformContext.current) - .data(backdropUrl) + .data(blurSourceUrl) .diskCachePolicy(CachePolicy.ENABLED) - .diskCacheKey(backdropUrl + "BIGGER") + .diskCacheKey(blurSourceUrl + "BIGGER") .build(), contentDescription = null, contentScale = ContentScale.Crop, @@ -244,8 +246,8 @@ fun NowPlayingContentAppleMusic( // Same fallback the artwork pager carries: maxresdefault is missing for plenty of // videos, and without this the page would simply stay black. onError = { - val fallback = backdropUrl?.replace("maxresdefault", "hqdefault") - if (fallback != null && fallback != backdropUrl) backdropUrl = fallback + val fallback = blurSourceUrl?.replace("maxresdefault", "hqdefault") + if (fallback != null && fallback != blurSourceUrl) backdropUrl = fallback }, modifier = Modifier.fillMaxSize().blur(BACKDROP_BLUR_RADIUS, BlurredEdgeTreatment.Unbounded), ) @@ -274,6 +276,8 @@ fun NowPlayingContentAppleMusic( activePillContainer = activePillContainer, activePillContent = activePillContent, deviceVolumeController = deviceVolumeController, + backdropUrl = backdropUrl, + backdropBrush = backdropBrush, ) AppleMusicView.LYRICS -> @@ -381,6 +385,8 @@ private fun AppleMusicMainView( activePillContainer: Color, activePillContent: Color, deviceVolumeController: DeviceVolumeController?, + backdropUrl: String?, + backdropBrush: Brush, ) { val screenInfo = getScreenSizeInfo() val localDensity = LocalDensity.current @@ -438,6 +444,9 @@ private fun AppleMusicMainView( onToggleVideoOverlay = { showVideoOverlay = !showVideoOverlay }, showSubtitle = showSubtitle, onToggleSubtitle = { showSubtitle = !showSubtitle }, + seedColor = seedColor, + backdropUrl = backdropUrl, + backdropBrush = backdropBrush, ) } @@ -753,6 +762,9 @@ private fun AppleMusicArtworkPage( onToggleVideoOverlay: () -> Unit, showSubtitle: Boolean, onToggleSubtitle: () -> Unit, + seedColor: Color, + backdropUrl: String?, + backdropBrush: Brush, ) { val pageTrack = state.artworkQueue.getOrNull(page) val isCurrentPage = page == state.currentOrderIndex @@ -761,13 +773,30 @@ private fun AppleMusicArtworkPage( Box(modifier = Modifier.fillMaxSize()) { if (isCurrentPage) { - var artworkUrl by remember(state.screenData.thumbnailURL) { mutableStateOf(state.screenData.thumbnailURL) } + val amArtwork = state.screenData.amArtworkData + var artworkUrl by remember(state.screenData.thumbnailURL, amArtwork?.staticArtworkUrl) { + val amUrl = amArtwork?.staticArtworkUrl + val url = if (!amUrl.isNullOrBlank()) amUrl else state.screenData.thumbnailURL + mutableStateOf(url) + } + val hasAmMotion = !pageShowsCanvasOrVideo && amArtwork?.hasMotion == true && amArtwork.bestMotionUrl != null + val isPlaying = state.controllerState.isPlaying + + val staticArtworkScale by animateFloatAsState( + targetValue = if (isPlaying) 1f else 0.87f, + animationSpec = tween(500, easing = androidx.compose.animation.core.FastOutSlowInEasing), + label = "staticArtworkScale" + ) + + var extractedEdgeColor by remember(artworkUrl) { mutableStateOf(Color.Transparent) } + Box( modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() .height(artworkZoneHeightDp.dp), + contentAlignment = Alignment.Center ) { AsyncImage( model = @@ -779,7 +808,39 @@ private fun AppleMusicArtworkPage( .crossfade(550) .build(), contentDescription = "", - onSuccess = { actions.onArtworkBitmap(it.result.image.toImageBitmap()) }, + onSuccess = { + val bitmap = it.result.image.toImageBitmap() + actions.onArtworkBitmap(bitmap) + + try { + val width = bitmap.width + val height = bitmap.height + val startY = (height * 0.90).toInt() + val regionHeight = height - startY + if (width > 0 && regionHeight > 0) { + val pixels = IntArray(width * regionHeight) + bitmap.readPixels( + buffer = pixels, + startX = 0, + startY = startY, + width = width, + height = regionHeight + ) + var r = 0L + var g = 0L + var b = 0L + for (pixel in pixels) { + r += (pixel shr 16) and 0xFF + g += (pixel shr 8) and 0xFF + b += pixel and 0xFF + } + val count = pixels.size.coerceAtLeast(1) + extractedEdgeColor = Color((r / count).toInt(), (g / count).toInt(), (b / count).toInt()) + } + } catch (e: Exception) { + extractedEdgeColor = seedColor + } + }, onError = { val fallback = artworkUrl?.replace("maxresdefault", "hqdefault") if (fallback != null && fallback != artworkUrl) artworkUrl = fallback @@ -787,15 +848,59 @@ private fun AppleMusicArtworkPage( contentScale = ContentScale.Crop, placeholder = rememberHolderPainter(), error = rememberHolderPainter(), - // The artwork DISSOLVES (alpha mask) instead of being covered by a colour - // overlay: that overlay had to land on exactly the page gradient's colour at - // that Y, and any drift drew a hard horizontal line across the screen. - // Masking lets the real background show through — nothing left to match. modifier = Modifier - .fillMaxSize() - .alpha(if (pageShowsCanvasOrVideo) 0f else 1f) - .appleMusicVerticalFadeEdges(topFade = 0.dp, bottomFade = 300.dp), + .fillMaxWidth(0.85f) + .aspectRatio(1f) + .scale(staticArtworkScale) + .shadow(elevation = 24.dp, shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), spotColor = Color.Black) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(8.dp)) + .alpha(if (pageShowsCanvasOrVideo || hasAmMotion) 0f else 1f), + ) + + if (hasAmMotion) { + val dominantColor = if (extractedEdgeColor != Color.Transparent) extractedEdgeColor else seedColor + Box(modifier = Modifier.fillMaxSize()) { + MediaPlayerView( + url = amArtwork!!.bestMotionUrl!!, + cropToBounds = true, + modifier = Modifier.fillMaxSize() + ) + + // Cover the bottom 10% of the video with a gradient fading into dominantColor + val coverHeight = (artworkZoneHeightDp * 0.10f).coerceAtLeast(30f).dp + Box( + modifier = Modifier + .fillMaxWidth() + .height(coverHeight) + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + 0f to Color.Transparent, + 1f to dominantColor + ) + ) + ) + } + } + } + + if (hasAmMotion) { + val dominantColor = if (extractedEdgeColor != Color.Transparent) extractedEdgeColor else seedColor + val bottomGradientColor = appleMusicGradientColorAt(dominantColor, 1f) + + // Seamless fill below the artwork zone to the bottom of the screen + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(top = artworkZoneHeightDp.dp) + .background( + Brush.verticalGradient( + 0f to dominantColor, + 1f to bottomGradientColor + ) + ) ) } if (pageShowsCanvasOrVideo) { From 3ee306bbacf0049f081d3c72dec847eacb4e1f23 Mon Sep 17 00:00:00 2001 From: HrishikeshBhandarkar Date: Sun, 6 Sep 2026 12:17:04 +0530 Subject: [PATCH 04/11] feat(album): add Apple Music motion cover and high-res artwork to Album screen --- .../simpmusic/ui/screen/other/AlbumScreen.kt | 152 +++++++++++------- .../simpmusic/viewModel/AlbumViewModel.kt | 15 ++ 2 files changed, 109 insertions(+), 58 deletions(-) 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 119e1e3dd..a95223a93 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 @@ -201,11 +201,18 @@ fun AlbumScreen( mutableStateOf(null) } - LaunchedEffect(bitmap) { + val hasAmMotion = uiState.amArtworkData?.hasMotion == true && uiState.amArtworkData?.bestMotionUrl != null + val currentArtworkUrl = if (hasAmMotion && uiState.amArtworkData?.staticArtworkUrl != null) { + uiState.amArtworkData!!.staticArtworkUrl + } else { + uiState.thumbnail + } + + LaunchedEffect(bitmap, currentArtworkUrl) { val bm = bitmap - if (bm != null && paletteGeneratedFor != uiState.thumbnail) { + if (bm != null && paletteGeneratedFor != currentArtworkUrl) { paletteState.generate(bm) - paletteGeneratedFor = uiState.thumbnail + paletteGeneratedFor = currentArtworkUrl } } @@ -255,50 +262,66 @@ fun AlbumScreen( horizontalAlignment = Alignment.Start, ) { if (isPortrait) { - // Apple Music-style: edge-to-edge artwork (taller than square, - // ~half screen height) with title overlay + liquid glass buttons. - // Glass buttons MUST be siblings of the backdrop source (not children) - // to avoid render feedback loop / RuntimeShader crash. + // Apple Music-style: edge-to-edge artwork with title overlay + liquid glass buttons. + // When animated artwork is present, header is sized 10% less than full motion height + // so that the bottom edge of the video is covered and fades into the dominant page background. + val amArtwork = uiState.amArtworkData + val hasAmMotion = amArtwork?.hasMotion == true && amArtwork.bestMotionUrl != null + val motionArtworkHeight = screenInfo.wDP * (4f / 3f) + val headerHeight = if (hasAmMotion) (motionArtworkHeight * 0.90f).dp else (screenInfo.hDP / 2).dp + val artworkHeight = if (hasAmMotion) motionArtworkHeight.dp else (screenInfo.hDP / 2).dp + val artworkBackdrop = rememberBackdrop(Color.Black) Box( modifier = Modifier .fillMaxWidth() - .height((screenInfo.hDP / 2).dp), + .height(headerHeight), ) { // 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 = rememberHolderPainter(), - error = rememberHolderPainter(), - contentDescription = null, - contentScale = ContentScale.Crop, - onSuccess = { - bitmap = it.result.image.toImageBitmap() - }, - modifier = Modifier.fillMaxSize(), - ) - // Subtle bottom gradient — keeps artwork visible behind - // the title text and blends artwork edge seamlessly into - // the muted palette page background (Apple Music style). - // Spans 70% of the artwork (not a fixed 200dp): the shorter - // the ramp, the steeper the alpha, and a steep ramp is what - // makes the fade read as an edge. Box( modifier = Modifier .fillMaxWidth() - .height((screenInfo.hDP * 0.35f).dp) + .height(artworkHeight) + .align(Alignment.TopCenter), + ) { + AsyncImage( + model = + ImageRequest + .Builder(LocalPlatformContext.current) + .data(currentArtworkUrl) + .diskCachePolicy(CachePolicy.ENABLED) + .memoryCachePolicy(CachePolicy.ENABLED) + .diskCacheKey(currentArtworkUrl) + .memoryCacheKey(currentArtworkUrl) + .crossfade(false) + .build(), + placeholder = rememberHolderPainter(), + error = rememberHolderPainter(), + contentDescription = null, + contentScale = ContentScale.Crop, + onSuccess = { + bitmap = it.result.image.toImageBitmap() + }, + modifier = Modifier.fillMaxSize(), + ) + + if (hasAmMotion) { + com.maxrave.simpmusic.expect.ui.MediaPlayerView( + url = amArtwork!!.bestMotionUrl!!, + cropToBounds = true, + modifier = Modifier.fillMaxSize() + ) + } + } + // Bottom gradient — melts artwork edge seamlessly into the dominant palette background + Box( + modifier = + Modifier + .fillMaxWidth() + .height(if (hasAmMotion) (motionArtworkHeight * 0.40f).dp else (screenInfo.hDP * 0.35f).dp) .align(Alignment.BottomCenter) .background(artworkScrimBrush(mutedPaletteBg)), ) @@ -424,29 +447,42 @@ fun AlbumScreen( horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.Top, ) { - 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 = rememberHolderPainter(), - error = rememberHolderPainter(), - contentDescription = null, - contentScale = ContentScale.Crop, - onSuccess = { - bitmap = it.result.image.toImageBitmap() - }, - modifier = - Modifier - .size(280.dp) - .clip(RoundedCornerShape(8.dp)), - ) + Box( + modifier = Modifier + .size(280.dp) + .clip(RoundedCornerShape(8.dp)) + ) { + AsyncImage( + model = + ImageRequest + .Builder(LocalPlatformContext.current) + .data(currentArtworkUrl) + .diskCachePolicy(CachePolicy.ENABLED) + .memoryCachePolicy(CachePolicy.ENABLED) + .diskCacheKey(currentArtworkUrl) + .memoryCacheKey(currentArtworkUrl) + .crossfade(false) + .build(), + placeholder = rememberHolderPainter(), + error = rememberHolderPainter(), + contentDescription = null, + contentScale = ContentScale.Crop, + onSuccess = { + bitmap = it.result.image.toImageBitmap() + }, + modifier = Modifier.fillMaxSize(), + ) + + val amArtwork = uiState.amArtworkData + val hasAmMotion = amArtwork?.hasMotion == true && amArtwork.bestMotionUrl != null + if (hasAmMotion) { + com.maxrave.simpmusic.expect.ui.MediaPlayerView( + url = amArtwork!!.bestMotionUrl!!, + cropToBounds = true, + modifier = Modifier.fillMaxSize() + ) + } + } Column( modifier = Modifier.weight(1f), ) { diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AlbumViewModel.kt index 01ff8b152..08b6c2579 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AlbumViewModel.kt @@ -39,11 +39,13 @@ class AlbumViewModel( private val albumRepository: AlbumRepository, ) : BaseViewModel() { private val downloadUtils: DownloadHandler by inject() + private val lyricsCanvasRepository: com.maxrave.domain.repository.LyricsCanvasRepository by inject() private val _uiState: MutableStateFlow = MutableStateFlow(AlbumUIState.initial()) val uiState: StateFlow = _uiState private var job: Job? = null private var collectDownloadStateJob: Job? = null + private var amArtworkJob: Job? = null fun updateBrowseId(browseId: String) { viewModelScope.launch { @@ -72,6 +74,18 @@ class AlbumViewModel( loadState = LocalPlaylistState.PlaylistLoadState.Success, ) } + val artistForSearch = data.artists.firstOrNull()?.name ?: "" + amArtworkJob?.cancel() + if (artistForSearch.isNotBlank()) { + amArtworkJob = viewModelScope.launch { + lyricsCanvasRepository.getAppleMusicAlbumArtwork(data.title, artistForSearch).collectLatest { res -> + val amArtwork = res.data + if (res is Resource.Success && amArtwork != null && amArtwork.hasMotion) { + _uiState.update { it.copy(amArtworkData = amArtwork) } + } + } + } + } val localAlbum = albumRepository.getAlbum(browseId).lastOrNull() if (localAlbum != null) { _uiState.update { @@ -298,6 +312,7 @@ data class AlbumUIState( val listTrack: List = emptyList(), val otherVersion: List = emptyList(), val loadState: LocalPlaylistState.PlaylistLoadState = LocalPlaylistState.PlaylistLoadState.Loading, + val amArtworkData: com.maxrave.domain.data.model.canvas.AppleMusicArtwork? = null, ) { companion object { fun initial(): AlbumUIState = AlbumUIState() From bdc643eac8f9417a78cfde135f73b94162b1be44 Mon Sep 17 00:00:00 2001 From: HrishikeshBhandarkar Date: Sun, 6 Sep 2026 13:50:21 +0530 Subject: [PATCH 05/11] feat(extension): add toHighQualityArtworkUrl for high resolution album art --- .../com/maxrave/simpmusic/extension/AllExt.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/AllExt.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/AllExt.kt index 7665a1714..0f95d488d 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/AllExt.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/AllExt.kt @@ -201,6 +201,32 @@ fun String.toSquareThumbnailUrl(): String { spec.replace(Regex("(^|-)([wh])\\d+")) { m -> "${m.groupValues[1]}${m.groupValues[2]}$min" } } +/** + * Forces high-resolution artwork for supported image hosts: + * - iTunes / Apple Music CDN: replaces dimensions with 1200x1200bb.jpg + * - Google / YouTube images (=w...-h... or =s...): bumps dimensions to 1200 + * - YouTube video thumbnails (i.ytimg.com): attempts maxresdefault.jpg + */ +fun String.toHighQualityArtworkUrl(): String { + if (isBlank()) return this + if (contains("{w}x{h}")) { + return replace("{w}x{h}", "1200x1200") + } + val amMatch = Regex("""/(?:[0-9]+x[0-9]+bb|source/[0-9]+x[0-9]+bb)\.(?:jpg|png|webp|jpeg)""").find(this) + if (amMatch != null) { + val base = substring(0, amMatch.range.first) + return "$base/1200x1200bb.jpg" + } + if (contains("googleusercontent.com") || contains("ggpht.com")) { + return this.replace(Regex("=w\\d+-h\\d+.*"), "=w1200-h1200") + .replace(Regex("=s\\d+.*"), "=s1200") + } + if (contains("i.ytimg.com/vi/")) { + return this.replace(Regex("/(?:hqdefault|sddefault|default|mqdefault)\\.jpg"), "/maxresdefault.jpg") + } + return this +} + fun isValidProxyHost(host: String): Boolean { // Regular expression to validate proxy host (without port) val proxyHostRegex = From 6c17750a37c92eb0f535ee3d82d1e39287eefdc8 Mon Sep 17 00:00:00 2001 From: HrishikeshBhandarkar Date: Sun, 6 Sep 2026 13:50:26 +0530 Subject: [PATCH 06/11] fix(album): preserve static Apple Music artwork and force high quality covers --- .../simpmusic/ui/screen/other/AlbumScreen.kt | 38 ++++++++++++------- .../simpmusic/viewModel/AlbumViewModel.kt | 2 +- 2 files changed, 26 insertions(+), 14 deletions(-) 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 a95223a93..07f6e5bba 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 @@ -77,6 +77,7 @@ import com.maxrave.simpmusic.expect.ui.toImageBitmap import com.maxrave.simpmusic.extension.artworkScrimBrush import com.maxrave.simpmusic.extension.getColorFromPalette import com.maxrave.simpmusic.extension.getScreenSizeInfo +import com.maxrave.simpmusic.extension.toHighQualityArtworkUrl import com.maxrave.simpmusic.extension.toImmersiveBackground import com.maxrave.simpmusic.ui.component.AddToPlaylistModalBottomSheet import com.maxrave.simpmusic.ui.component.CenterLoadingBox @@ -202,17 +203,16 @@ fun AlbumScreen( } val hasAmMotion = uiState.amArtworkData?.hasMotion == true && uiState.amArtworkData?.bestMotionUrl != null - val currentArtworkUrl = if (hasAmMotion && uiState.amArtworkData?.staticArtworkUrl != null) { - uiState.amArtworkData!!.staticArtworkUrl - } else { - uiState.thumbnail + val currentArtworkUrl = (uiState.amArtworkData?.staticArtworkUrl ?: uiState.thumbnail)?.toHighQualityArtworkUrl() + var resolvedArtworkUrl by remember(currentArtworkUrl) { + mutableStateOf(currentArtworkUrl) } - LaunchedEffect(bitmap, currentArtworkUrl) { + LaunchedEffect(bitmap, resolvedArtworkUrl) { val bm = bitmap - if (bm != null && paletteGeneratedFor != currentArtworkUrl) { + if (bm != null && paletteGeneratedFor != resolvedArtworkUrl) { paletteState.generate(bm) - paletteGeneratedFor = currentArtworkUrl + paletteGeneratedFor = resolvedArtworkUrl } } @@ -291,17 +291,23 @@ fun AlbumScreen( model = ImageRequest .Builder(LocalPlatformContext.current) - .data(currentArtworkUrl) + .data(resolvedArtworkUrl) .diskCachePolicy(CachePolicy.ENABLED) .memoryCachePolicy(CachePolicy.ENABLED) - .diskCacheKey(currentArtworkUrl) - .memoryCacheKey(currentArtworkUrl) + .diskCacheKey(resolvedArtworkUrl) + .memoryCacheKey(resolvedArtworkUrl) .crossfade(false) .build(), placeholder = rememberHolderPainter(), error = rememberHolderPainter(), contentDescription = null, contentScale = ContentScale.Crop, + onError = { + val fallback = resolvedArtworkUrl?.replace("maxresdefault", "hqdefault") + if (fallback != null && fallback != resolvedArtworkUrl) { + resolvedArtworkUrl = fallback + } + }, onSuccess = { bitmap = it.result.image.toImageBitmap() }, @@ -456,17 +462,23 @@ fun AlbumScreen( model = ImageRequest .Builder(LocalPlatformContext.current) - .data(currentArtworkUrl) + .data(resolvedArtworkUrl) .diskCachePolicy(CachePolicy.ENABLED) .memoryCachePolicy(CachePolicy.ENABLED) - .diskCacheKey(currentArtworkUrl) - .memoryCacheKey(currentArtworkUrl) + .diskCacheKey(resolvedArtworkUrl) + .memoryCacheKey(resolvedArtworkUrl) .crossfade(false) .build(), placeholder = rememberHolderPainter(), error = rememberHolderPainter(), contentDescription = null, contentScale = ContentScale.Crop, + onError = { + val fallback = resolvedArtworkUrl?.replace("maxresdefault", "hqdefault") + if (fallback != null && fallback != resolvedArtworkUrl) { + resolvedArtworkUrl = fallback + } + }, onSuccess = { bitmap = it.result.image.toImageBitmap() }, diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AlbumViewModel.kt index 08b6c2579..701736da6 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/AlbumViewModel.kt @@ -80,7 +80,7 @@ class AlbumViewModel( amArtworkJob = viewModelScope.launch { lyricsCanvasRepository.getAppleMusicAlbumArtwork(data.title, artistForSearch).collectLatest { res -> val amArtwork = res.data - if (res is Resource.Success && amArtwork != null && amArtwork.hasMotion) { + if (res is Resource.Success && amArtwork != null && amArtwork.found) { _uiState.update { it.copy(amArtworkData = amArtwork) } } } From 8c42686e8b509c7046a8cd28a5ba5fac1e15d34e Mon Sep 17 00:00:00 2001 From: HrishikeshBhandarkar Date: Sun, 6 Sep 2026 13:50:31 +0530 Subject: [PATCH 07/11] feat(player): prioritize Apple Music live artwork over Spotify Canvas with concurrent caching --- .../content/NowPlayingContentAppleMusic.kt | 37 +++--- .../simpmusic/viewModel/SharedViewModel.kt | 110 +++++++++++++----- 2 files changed, 107 insertions(+), 40 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/content/NowPlayingContentAppleMusic.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/content/NowPlayingContentAppleMusic.kt index c967b16d1..2231846bf 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/content/NowPlayingContentAppleMusic.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/content/NowPlayingContentAppleMusic.kt @@ -85,6 +85,7 @@ import com.maxrave.simpmusic.expect.ui.rememberDeviceVolumeController import com.maxrave.simpmusic.expect.ui.toImageBitmap import com.maxrave.simpmusic.extension.getScreenSizeInfo import com.maxrave.simpmusic.extension.smoothScrimBrush +import com.maxrave.simpmusic.extension.toHighQualityArtworkUrl import com.maxrave.simpmusic.getPlatform import com.maxrave.simpmusic.ui.component.ExplicitBadge import com.maxrave.simpmusic.ui.component.LiquidGlassIconButton @@ -173,9 +174,9 @@ fun NowPlayingContentAppleMusic( // work rather than a second one that has to be kept working. var backdropUrl by remember(state.screenData.thumbnailURL, state.screenData.amArtworkData) { val amData = state.screenData.amArtworkData - val amUrl = if (amData?.hasMotion == true) amData.staticArtworkUrl else null + val amUrl = amData?.staticArtworkUrl val url = if (!amUrl.isNullOrBlank()) amUrl else state.screenData.thumbnailURL - mutableStateOf(url) + mutableStateOf(url?.toHighQualityArtworkUrl()) } val paletteColor = state.startColor.value @@ -183,10 +184,14 @@ fun NowPlayingContentAppleMusic( val activePillContainer = remember(seedColor) { lerp(seedColor, Color.White, 0.75f) } val activePillContent = remember(seedColor) { lerp(seedColor, Color.Black, 0.6f) } + val amData = state.screenData.amArtworkData + val isAmLoading = state.screenData.isAmArtworkLoading + val hasAmMotion = amData?.hasMotion == true && amData.bestMotionUrl != null + val canShowCanvas = !hasAmMotion && !isAmLoading && state.screenData.canvasData != null val showCanvasBackdrop = viewState == AppleMusicView.MAIN && - (state.screenData.canvasData != null || (state.screenData.isVideo && state.shouldShowVideo)) - val isVideoBackdropTop = showCanvasBackdrop && state.screenData.canvasData == null + (canShowCanvas || (state.screenData.isVideo && state.shouldShowVideo)) + val isVideoBackdropTop = showCanvasBackdrop && !canShowCanvas // The approved mock's page gradient is THREE stops — a clearly-tinted top, ~55%-darkened by // mid-page (48%), warm near-black at the bottom. The first cut's two stops to near-black read @@ -226,7 +231,7 @@ fun NowPlayingContentAppleMusic( // of the artwork stay visible through it. // When lyrics view is open, the background blur strictly follows the static album art sequence. val blurSourceUrl = if (viewState == AppleMusicView.LYRICS) { - state.screenData.thumbnailURL + state.screenData.thumbnailURL?.toHighQualityArtworkUrl() } else { backdropUrl } @@ -392,9 +397,13 @@ private fun AppleMusicMainView( val localDensity = LocalDensity.current val isRepeatOne = state.controllerState.repeatState is RepeatState.One + val amData = state.screenData.amArtworkData + val isAmLoading = state.screenData.isAmArtworkLoading + val hasAmMotion = amData?.hasMotion == true && amData.bestMotionUrl != null + val canShowCanvas = !hasAmMotion && !isAmLoading && state.screenData.canvasData != null val showCanvasBackdrop = - state.screenData.canvasData != null || (state.screenData.isVideo && state.shouldShowVideo) - val isVideoBackdrop = showCanvasBackdrop && state.screenData.canvasData == null + canShowCanvas || (state.screenData.isVideo && state.shouldShowVideo) + val isVideoBackdrop = showCanvasBackdrop && !canShowCanvas // Same fade/half-blended-frame fix M3E uses: fast fade-in, relaxed fade-out. val controlsAlpha by animateFloatAsState( @@ -654,9 +663,9 @@ private fun AppleMusicMainView( model = ImageRequest .Builder(LocalPlatformContext.current) - .data(state.screenData.thumbnailURL) + .data(state.screenData.thumbnailURL?.toHighQualityArtworkUrl()) .diskCachePolicy(CachePolicy.ENABLED) - .diskCacheKey(state.screenData.thumbnailURL) + .diskCacheKey(state.screenData.thumbnailURL?.toHighQualityArtworkUrl()) .crossfade(300) .build(), placeholder = rememberHolderPainter(), @@ -768,18 +777,20 @@ private fun AppleMusicArtworkPage( ) { val pageTrack = state.artworkQueue.getOrNull(page) val isCurrentPage = page == state.currentOrderIndex + val amArtwork = state.screenData.amArtworkData + val isAmLoading = isCurrentPage && state.screenData.isAmArtworkLoading + val hasAmMotion = isCurrentPage && amArtwork?.hasMotion == true && amArtwork.bestMotionUrl != null + val canShowCanvas = isCurrentPage && !hasAmMotion && !isAmLoading && state.screenData.canvasData != null val pageShowsCanvasOrVideo = - isCurrentPage && (state.screenData.canvasData != null || (state.screenData.isVideo && state.shouldShowVideo)) + canShowCanvas || (isCurrentPage && state.screenData.isVideo && state.shouldShowVideo) Box(modifier = Modifier.fillMaxSize()) { if (isCurrentPage) { - val amArtwork = state.screenData.amArtworkData var artworkUrl by remember(state.screenData.thumbnailURL, amArtwork?.staticArtworkUrl) { val amUrl = amArtwork?.staticArtworkUrl val url = if (!amUrl.isNullOrBlank()) amUrl else state.screenData.thumbnailURL - mutableStateOf(url) + mutableStateOf(url?.toHighQualityArtworkUrl()) } - val hasAmMotion = !pageShowsCanvasOrVideo && amArtwork?.hasMotion == true && amArtwork.bestMotionUrl != null val isPlaying = state.controllerState.isPlaying val staticArtworkScale by animateFloatAsState( diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt index 07b861700..5cbb3a974 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt @@ -93,6 +93,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.compose.resources.getString import org.simpmusic.lastfm.completeLogin import simpmusic.composeapp.generated.resources.Res @@ -266,7 +267,7 @@ class SharedViewModel( Logger.w(tag, "MediaId is ${nowPlaying.mediaItem.mediaId}") getCanvas(nowPlaying.mediaItem.mediaId, (timeline.total / 1000).toInt()) } - if (nowPlaying.mediaItem.isSong() && nowPlayingScreenData.value.amArtworkData == null) { + if (nowPlaying.mediaItem.isSong() && nowPlayingScreenData.value.amArtworkData == null && !nowPlayingScreenData.value.isAmArtworkLoading) { nowPlaying.songEntity?.let { song -> getAppleMusicArtwork( videoId = song.videoId, @@ -378,11 +379,23 @@ class SharedViewModel( lyricsData = null, songInfoData = null, amArtworkData = null, + isAmArtworkLoading = true, playlistName = mediaPlayerHandler.queueData.value ?.data ?.playlistName ?: "", ) + getAppleMusicArtwork( + videoId = track.videoId, + title = track.title, + artist = track.artistName?.joinToString(", ") ?: "", + album = track.albumName, + ) + val trackDuration = track.durationSeconds.takeIf { it > 0 } + ?: (seededTotal.takeIf { it > 0L }?.div(1000)?.toInt()) + if (trackDuration != null && trackDuration > 0) { + getCanvas(track.videoId, trackDuration) + } } state.mediaItem.let { now -> _canvas.value = null @@ -585,10 +598,21 @@ class SharedViewModel( } } + private val canvasCache = mutableMapOf() + private fun getCanvas( videoId: String, duration: Int, ) { + if (canvasCache.containsKey(videoId)) { + val cached = canvasCache[videoId] + Logger.d(tag, "getCanvas: cache hit for $videoId") + if (nowPlayingState.value?.mediaItem?.mediaId == videoId) { + _nowPlayingScreenData.update { it.copy(canvasData = cached) } + } + return + } + Logger.w(tag, "Start getCanvas: $videoId $duration") // canvasJob?.cancel() viewModelScope.launch { @@ -605,7 +629,10 @@ class SharedViewModel( add(lyricsCanvasRepository.getCanvas(dataStoreManager, videoId, duration)) } } - if (sources.isEmpty()) return@launch + if (sources.isEmpty()) { + canvasCache[videoId] = null + return@launch + } var resolved = false for (source in sources) { @@ -615,14 +642,14 @@ class SharedViewModel( if (response is Resource.Success && data != null && nowPlayingState.value?.mediaItem?.mediaId == videoId) { resolved = true _canvas.value = data - _nowPlayingScreenData.update { - it.copy( - canvasData = - NowPlayingScreenData.CanvasData( - isVideo = data.isVideo, - url = data.canvasUrl, - ), + val cData = + NowPlayingScreenData.CanvasData( + isVideo = data.isVideo, + url = data.canvasUrl, ) + canvasCache[videoId] = cData + _nowPlayingScreenData.update { + it.copy(canvasData = cData) } // Save canvas video url if (data.isVideo) lyricsCanvasRepository.updateCanvasUrl(videoId, data.canvasUrl) @@ -635,22 +662,26 @@ class SharedViewModel( } if (!resolved) { - nowPlayingState.value?.songEntity?.canvasUrl?.let { url -> - _nowPlayingScreenData.update { - it.copy( - canvasData = - NowPlayingScreenData.CanvasData( - isVideo = url.isCanvasVideoUrl(), - url = url, - ), + val dbUrl = nowPlayingState.value?.songEntity?.canvasUrl + if (dbUrl != null) { + val cData = + NowPlayingScreenData.CanvasData( + isVideo = dbUrl.isCanvasVideoUrl(), + url = dbUrl, ) + canvasCache[videoId] = cData + _nowPlayingScreenData.update { + it.copy(canvasData = cData) } + } else { + canvasCache[videoId] = null } } } } private var amArtworkJob: Job? = null + private val amArtworkCache = mutableMapOf() private fun getAppleMusicArtwork( videoId: String, @@ -658,22 +689,45 @@ class SharedViewModel( artist: String, album: String?, ) { - Logger.w(tag, "Start getAppleMusicArtwork: $videoId") + val cacheKey = "$title - $artist" + if (amArtworkCache.containsKey(cacheKey)) { + val cached = amArtworkCache[cacheKey] + Logger.d(tag, "getAppleMusicArtwork: cache hit for $cacheKey (found=${cached?.found}, motion=${cached?.hasMotion})") + if (nowPlayingState.value?.mediaItem?.mediaId == videoId) { + _nowPlayingScreenData.update { + it.copy(amArtworkData = cached, isAmArtworkLoading = false) + } + } + return + } + + Logger.w(tag, "Start getAppleMusicArtwork: $videoId ($title - $artist)") amArtworkJob?.cancel() + _nowPlayingScreenData.update { it.copy(isAmArtworkLoading = true) } amArtworkJob = viewModelScope.launch { if (dataStoreManager.nowPlayingStyle.first() == DataStoreManager.NOW_PLAYING_STYLE_APPLE_MUSIC) { - lyricsCanvasRepository.getAppleMusicSongArtwork(title, artist, album).collect { response -> - val data = response.data - if (response is Resource.Success && data != null && data.hasMotion && nowPlayingState.value?.mediaItem?.mediaId == videoId) { - _nowPlayingScreenData.update { - it.copy(amArtworkData = data) - } - } else if (nowPlayingState.value?.mediaItem?.mediaId == videoId) { - _nowPlayingScreenData.update { - it.copy(amArtworkData = null) + withTimeoutOrNull(3000L) { + lyricsCanvasRepository.getAppleMusicSongArtwork(title, artist, album).collect { response -> + val data = response.data + if (response is Resource.Success && data != null && data.found && nowPlayingState.value?.mediaItem?.mediaId == videoId) { + amArtworkCache[cacheKey] = data + _nowPlayingScreenData.update { + it.copy(amArtworkData = data, isAmArtworkLoading = false) + } + } else if (nowPlayingState.value?.mediaItem?.mediaId == videoId) { + amArtworkCache[cacheKey] = null + _nowPlayingScreenData.update { + it.copy(amArtworkData = null, isAmArtworkLoading = false) + } } } + } ?: run { + if (nowPlayingState.value?.mediaItem?.mediaId == videoId) { + _nowPlayingScreenData.update { it.copy(isAmArtworkLoading = false) } + } } + } else { + _nowPlayingScreenData.update { it.copy(isAmArtworkLoading = false) } } } } @@ -2160,6 +2214,7 @@ data class NowPlayingScreenData( val songInfoData: SongInfoEntity? = null, val bitmap: ImageBitmap? = null, val amArtworkData: com.maxrave.domain.data.model.canvas.AppleMusicArtwork? = null, + val isAmArtworkLoading: Boolean = false, ) { data class CanvasData( val isVideo: Boolean, @@ -2183,6 +2238,7 @@ data class NowPlayingScreenData( lyricsData = null, songInfoData = null, amArtworkData = null, + isAmArtworkLoading = false, playlistName = "", ) } From d732e89512d2bb04dc88c67806587d3e046c0fd7 Mon Sep 17 00:00:00 2001 From: HrishikeshBhandarkar Date: Sun, 6 Sep 2026 13:57:28 +0530 Subject: [PATCH 08/11] feat(core): update core with 1080p live artwork playback --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 83f8a316f..efb767692 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 83f8a316f745fdc5173301bc8fadce0b993fe6c9 +Subproject commit efb767692399d82d89e8c450600509801e64aa3a From dbb1c52a57f9a4f1bb49f7e7d79affd8e4b0dc27 Mon Sep 17 00:00:00 2001 From: HrishikeshBhandarkar Date: Sun, 6 Sep 2026 15:08:41 +0530 Subject: [PATCH 09/11] refactor: remove toHighQualityArtworkUrl helper --- .../com/maxrave/simpmusic/extension/AllExt.kt | 26 ------------------- .../simpmusic/ui/screen/other/AlbumScreen.kt | 3 +-- .../content/NowPlayingContentAppleMusic.kt | 11 ++++---- 3 files changed, 6 insertions(+), 34 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/AllExt.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/AllExt.kt index 0f95d488d..7665a1714 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/AllExt.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/extension/AllExt.kt @@ -201,32 +201,6 @@ fun String.toSquareThumbnailUrl(): String { spec.replace(Regex("(^|-)([wh])\\d+")) { m -> "${m.groupValues[1]}${m.groupValues[2]}$min" } } -/** - * Forces high-resolution artwork for supported image hosts: - * - iTunes / Apple Music CDN: replaces dimensions with 1200x1200bb.jpg - * - Google / YouTube images (=w...-h... or =s...): bumps dimensions to 1200 - * - YouTube video thumbnails (i.ytimg.com): attempts maxresdefault.jpg - */ -fun String.toHighQualityArtworkUrl(): String { - if (isBlank()) return this - if (contains("{w}x{h}")) { - return replace("{w}x{h}", "1200x1200") - } - val amMatch = Regex("""/(?:[0-9]+x[0-9]+bb|source/[0-9]+x[0-9]+bb)\.(?:jpg|png|webp|jpeg)""").find(this) - if (amMatch != null) { - val base = substring(0, amMatch.range.first) - return "$base/1200x1200bb.jpg" - } - if (contains("googleusercontent.com") || contains("ggpht.com")) { - return this.replace(Regex("=w\\d+-h\\d+.*"), "=w1200-h1200") - .replace(Regex("=s\\d+.*"), "=s1200") - } - if (contains("i.ytimg.com/vi/")) { - return this.replace(Regex("/(?:hqdefault|sddefault|default|mqdefault)\\.jpg"), "/maxresdefault.jpg") - } - return this -} - fun isValidProxyHost(host: String): Boolean { // Regular expression to validate proxy host (without port) val proxyHostRegex = 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 07f6e5bba..ab17a781e 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 @@ -77,7 +77,6 @@ import com.maxrave.simpmusic.expect.ui.toImageBitmap import com.maxrave.simpmusic.extension.artworkScrimBrush import com.maxrave.simpmusic.extension.getColorFromPalette import com.maxrave.simpmusic.extension.getScreenSizeInfo -import com.maxrave.simpmusic.extension.toHighQualityArtworkUrl import com.maxrave.simpmusic.extension.toImmersiveBackground import com.maxrave.simpmusic.ui.component.AddToPlaylistModalBottomSheet import com.maxrave.simpmusic.ui.component.CenterLoadingBox @@ -203,7 +202,7 @@ fun AlbumScreen( } val hasAmMotion = uiState.amArtworkData?.hasMotion == true && uiState.amArtworkData?.bestMotionUrl != null - val currentArtworkUrl = (uiState.amArtworkData?.staticArtworkUrl ?: uiState.thumbnail)?.toHighQualityArtworkUrl() + val currentArtworkUrl = uiState.amArtworkData?.staticArtworkUrl ?: uiState.thumbnail var resolvedArtworkUrl by remember(currentArtworkUrl) { mutableStateOf(currentArtworkUrl) } diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/content/NowPlayingContentAppleMusic.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/content/NowPlayingContentAppleMusic.kt index 2231846bf..3c7f6b00b 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/content/NowPlayingContentAppleMusic.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/content/NowPlayingContentAppleMusic.kt @@ -85,7 +85,6 @@ import com.maxrave.simpmusic.expect.ui.rememberDeviceVolumeController import com.maxrave.simpmusic.expect.ui.toImageBitmap import com.maxrave.simpmusic.extension.getScreenSizeInfo import com.maxrave.simpmusic.extension.smoothScrimBrush -import com.maxrave.simpmusic.extension.toHighQualityArtworkUrl import com.maxrave.simpmusic.getPlatform import com.maxrave.simpmusic.ui.component.ExplicitBadge import com.maxrave.simpmusic.ui.component.LiquidGlassIconButton @@ -176,7 +175,7 @@ fun NowPlayingContentAppleMusic( val amData = state.screenData.amArtworkData val amUrl = amData?.staticArtworkUrl val url = if (!amUrl.isNullOrBlank()) amUrl else state.screenData.thumbnailURL - mutableStateOf(url?.toHighQualityArtworkUrl()) + mutableStateOf(url) } val paletteColor = state.startColor.value @@ -231,7 +230,7 @@ fun NowPlayingContentAppleMusic( // of the artwork stay visible through it. // When lyrics view is open, the background blur strictly follows the static album art sequence. val blurSourceUrl = if (viewState == AppleMusicView.LYRICS) { - state.screenData.thumbnailURL?.toHighQualityArtworkUrl() + state.screenData.thumbnailURL } else { backdropUrl } @@ -663,9 +662,9 @@ private fun AppleMusicMainView( model = ImageRequest .Builder(LocalPlatformContext.current) - .data(state.screenData.thumbnailURL?.toHighQualityArtworkUrl()) + .data(state.screenData.thumbnailURL) .diskCachePolicy(CachePolicy.ENABLED) - .diskCacheKey(state.screenData.thumbnailURL?.toHighQualityArtworkUrl()) + .diskCacheKey(state.screenData.thumbnailURL) .crossfade(300) .build(), placeholder = rememberHolderPainter(), @@ -789,7 +788,7 @@ private fun AppleMusicArtworkPage( var artworkUrl by remember(state.screenData.thumbnailURL, amArtwork?.staticArtworkUrl) { val amUrl = amArtwork?.staticArtworkUrl val url = if (!amUrl.isNullOrBlank()) amUrl else state.screenData.thumbnailURL - mutableStateOf(url?.toHighQualityArtworkUrl()) + mutableStateOf(url) } val isPlaying = state.controllerState.isPlaying From 6f538064a3a787431292afdc6e54fbcb115dc1ac Mon Sep 17 00:00:00 2001 From: HrishikeshBhandarkar Date: Sun, 6 Sep 2026 15:30:05 +0530 Subject: [PATCH 10/11] fix(player): increase Apple Music artwork timeout to 8s and enable adaptive 1080p streaming --- .../simpmusic/viewModel/SharedViewModel.kt | 31 +++++++++++++------ core | 2 +- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt index 5cbb3a974..1157a6f90 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt @@ -706,23 +706,36 @@ class SharedViewModel( _nowPlayingScreenData.update { it.copy(isAmArtworkLoading = true) } amArtworkJob = viewModelScope.launch { if (dataStoreManager.nowPlayingStyle.first() == DataStoreManager.NOW_PLAYING_STYLE_APPLE_MUSIC) { - withTimeoutOrNull(3000L) { + withTimeoutOrNull(8000L) { lyricsCanvasRepository.getAppleMusicSongArtwork(title, artist, album).collect { response -> val data = response.data - if (response is Resource.Success && data != null && data.found && nowPlayingState.value?.mediaItem?.mediaId == videoId) { - amArtworkCache[cacheKey] = data - _nowPlayingScreenData.update { - it.copy(amArtworkData = data, isAmArtworkLoading = false) + when (response) { + is Resource.Success -> { + if (data != null && data.found && nowPlayingState.value?.mediaItem?.mediaId == videoId) { + amArtworkCache[cacheKey] = data + _nowPlayingScreenData.update { + it.copy(amArtworkData = data, isAmArtworkLoading = false) + } + } else if (nowPlayingState.value?.mediaItem?.mediaId == videoId) { + amArtworkCache[cacheKey] = null + _nowPlayingScreenData.update { + it.copy(amArtworkData = null, isAmArtworkLoading = false) + } + } } - } else if (nowPlayingState.value?.mediaItem?.mediaId == videoId) { - amArtworkCache[cacheKey] = null - _nowPlayingScreenData.update { - it.copy(amArtworkData = null, isAmArtworkLoading = false) + is Resource.Error -> { + if (nowPlayingState.value?.mediaItem?.mediaId == videoId) { + amArtworkCache[cacheKey] = null + _nowPlayingScreenData.update { + it.copy(amArtworkData = null, isAmArtworkLoading = false) + } + } } } } } ?: run { if (nowPlayingState.value?.mediaItem?.mediaId == videoId) { + Logger.w(tag, "getAppleMusicArtwork: timed out after 8s for $cacheKey") _nowPlayingScreenData.update { it.copy(isAmArtworkLoading = false) } } } diff --git a/core b/core index efb767692..1176dd659 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit efb767692399d82d89e8c450600509801e64aa3a +Subproject commit 1176dd659ce1bc9e8b85425f6caf8a382eac6af6 From 99949d853029c671abced39fb6e9c4183fe44577 Mon Sep 17 00:00:00 2001 From: HrishikeshBhandarkar Date: Sun, 6 Sep 2026 15:44:25 +0530 Subject: [PATCH 11/11] fix(player): initialize cache fields before ViewModel init to avoid NPE --- .../com/maxrave/simpmusic/viewModel/SharedViewModel.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt index 1157a6f90..5b1a00416 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt @@ -236,6 +236,10 @@ class SharedViewModel( private val _shareSavedLyrics: MutableStateFlow = MutableStateFlow(true) val shareSavedLyrics: StateFlow get() = _shareSavedLyrics + private val canvasCache = mutableMapOf() + private var amArtworkJob: Job? = null + private val amArtworkCache = mutableMapOf() + init { viewModelScope.launch { log("SharedViewModel init") @@ -598,8 +602,6 @@ class SharedViewModel( } } - private val canvasCache = mutableMapOf() - private fun getCanvas( videoId: String, duration: Int, @@ -680,9 +682,6 @@ class SharedViewModel( } } - private var amArtworkJob: Job? = null - private val amArtworkCache = mutableMapOf() - private fun getAppleMusicArtwork( videoId: String, title: String,