Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## [Unreleased]

### Added
- **Lyrics Share:** Export and share styled lyric cards (Song and Lyrics modes) with dynamic palette extraction, customizable color swatches, full multilingual support (Latin, Devanagari, Arabic, CJK), and system share sheet & MediaStore gallery export.

## [4.2.4] - 2026-10-05

### Added & Improved
Expand Down
85 changes: 85 additions & 0 deletions app/src/main/java/com/lastwave/app/ui/player/ModernLyricsView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.FormatSize
import androidx.compose.material.icons.filled.Share
import com.lastwave.app.ui.player.lyricsshare.ShareLyricsDialog
import com.lastwave.app.ui.player.lyricsshare.TrackInfo
import com.lastwave.app.ui.player.lyricsshare.buildLyricsSnapshot
import kotlin.math.roundToInt
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
Expand Down Expand Up @@ -196,9 +200,15 @@ fun ModernLyricsPanel(
primaryColor: Color = MaterialTheme.colorScheme.primary,
secondaryColor: Color = MaterialTheme.colorScheme.secondary,
tertiaryColor: Color = MaterialTheme.colorScheme.tertiary,
resolvedArtworkUrl: String? = null,
) {
state.current ?: return

var showShareDialog by rememberSaveable { mutableStateOf(false) }
val canShareLyrics = lyricsState is LyricsUiState.Success &&
!lyricsState.isInstrumental &&
(lyricsState.lines.any { it.text.isNotBlank() } || !lyricsState.plainLyrics.isNullOrBlank())

// High-frequency progress is collected ONLY in the leaf hosts below
// (lyrics list + controls). Collecting it here would recompose the whole
// panel incl. AnimatedContent + badge ~16x/sec. Other players isolate it.
Expand Down Expand Up @@ -606,12 +616,46 @@ fun ModernLyricsPanel(
primaryColor = primaryColor,
secondaryColor = secondaryColor,
tertiaryColor = tertiaryColor,
canShareLyrics = canShareLyrics,
onShareLyrics = { showShareDialog = true },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp, top = 6.dp),
)
}

if (showShareDialog) {
val currentTrack = state.current
if (currentTrack != null) {
val trackInfo = remember(currentTrack, resolvedArtworkUrl) {
TrackInfo(
title = currentTrack.title,
artist = currentTrack.artist,
artworkUrl = currentTrack.artworkUrl,
resolvedArtworkUrl = resolvedArtworkUrl,
)
}
val activeIndex = remember(lyricsState, lyricsOffsetMs) {
val lines = (lyricsState as? LyricsUiState.Success)?.lines.orEmpty()
if (lines.isNotEmpty()) {
activeLineIndex(lines, progressFlow.value.positionMs + lyricsOffsetMs)
} else {
-1
}
}
val snapshot = remember(lyricsState, trackInfo) {
buildLyricsSnapshot(lyricsState, trackInfo, activeIndex)
}
if (snapshot != null) {
ShareLyricsDialog(
snapshot = snapshot,
onDismiss = { showShareDialog = false },
primaryColor = primaryColor,
)
}
}
}
}

/**
Expand Down Expand Up @@ -1393,6 +1437,8 @@ private fun ModernLyricsControlsHost(
primaryColor: Color = MaterialTheme.colorScheme.primary,
secondaryColor: Color = MaterialTheme.colorScheme.secondary,
tertiaryColor: Color = MaterialTheme.colorScheme.tertiary,
canShareLyrics: Boolean = false,
onShareLyrics: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val progress by progressFlow.collectAsStateWithLifecycle(initialValue = progressInitial)
Expand Down Expand Up @@ -1421,6 +1467,8 @@ private fun ModernLyricsControlsHost(
primaryColor = primaryColor,
secondaryColor = secondaryColor,
tertiaryColor = tertiaryColor,
canShareLyrics = canShareLyrics,
onShareLyrics = onShareLyrics,
modifier = modifier,
)
}
Expand Down Expand Up @@ -1451,6 +1499,8 @@ private fun ModernLyricsControls(
primaryColor: Color = MaterialTheme.colorScheme.primary,
secondaryColor: Color = MaterialTheme.colorScheme.secondary,
tertiaryColor: Color = MaterialTheme.colorScheme.tertiary,
canShareLyrics: Boolean = false,
onShareLyrics: () -> Unit = {},
modifier: Modifier = Modifier,
) {
var sliderPosition by remember(lyricsFontScale) { mutableFloatStateOf(lyricsFontScale) }
Expand Down Expand Up @@ -1656,6 +1706,41 @@ private fun ModernLyricsControls(

Spacer(Modifier.width(10.dp))

if (canShareLyrics) {
val shareInteraction = remember { MutableInteractionSource() }
val isSharePressed by shareInteraction.collectIsPressedAsState()
val sharePressScale by animateFloatAsState(
targetValue = if (isSharePressed) 0.82f else 1.0f,
animationSpec = ExpressiveMotion.spatialSpring(),
label = "shareBtnScale",
)
IconButton(
onClick = onShareLyrics,
enabled = controlsVisible,
interactionSource = shareInteraction,
modifier = Modifier
.size(44.dp)
.graphicsLayer {
scaleX = sharePressScale
scaleY = sharePressScale
}
.clip(CircleShape)
.liquidGlassChrome(CircleShape, LocalLiquidGlass.current, LiquidGlassPreset.FloatingControls, interactionSource = shareInteraction)
.background(
liquidGlassContainerColor(Color.White.copy(alpha = 0.14f)),
),
) {
Icon(
Icons.Filled.Share,
contentDescription = stringResource(R.string.lyshare_button_cd),
modifier = Modifier.size(22.dp),
tint = Color.White.copy(alpha = 0.90f),
)
}

Spacer(Modifier.width(10.dp))
}

// Lyrics Font Scale toggle button placed immediately to the LEFT of Full Screen
val fontInteraction = remember { MutableInteractionSource() }
val isFontPressed by fontInteraction.collectIsPressedAsState()
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2420,6 +2420,7 @@ private fun FullPlayer(
primaryColor = ambientColor,
secondaryColor = ambientCompanion,
tertiaryColor = ambientDeep,
resolvedArtworkUrl = resolvedAmbientUrl,
modifier = Modifier
.fillMaxSize()
.adaptiveContentWidth(maxWidth = if (isTabletLandscape) 960.dp else 720.dp),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package com.lastwave.app.ui.player.lyricsshare

import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.graphics.drawable.BitmapDrawable
import coil.imageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import com.lastwave.app.data.artwork.ArtworkNormalizer
import com.lastwave.app.ui.player.FastBlur
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull

data class ArtworkResolution(
val coverBitmap: Bitmap?,
val blurredBitmap: Bitmap,
val resolvedUrl: String?,
)

object ArtworkResolver {

private const val LOAD_TIMEOUT_MS = 5000L

/**
* Resolves high-resolution album artwork following the app's established hierarchy:
* 1. Track's own artworkUrl (if real image)
* 2. Resolved ambient artwork URL from PlayerHost / ArtworkRepository
* 3. Upgraded high-res URL via ArtworkNormalizer.upscaleYoutubeArtwork()
* 4. File / content URIs for downloaded or local audio
* 5. Fallback brand gradient if all else fails or times out
*
* Decodes with allowHardware(false) for software Canvas drawing and Palette access.
*/
suspend fun resolveArtwork(
context: Context,
trackInfo: TrackInfo,
themePrimaryColor: Int = ColorContrastHelper.BRAND_LIME,
): ArtworkResolution = withContext(Dispatchers.IO) {
val targetUrl = determineArtworkUrl(trackInfo)

val loadedBitmap = if (!targetUrl.isNullOrBlank()) {
withTimeoutOrNull(LOAD_TIMEOUT_MS) {
runCatching {
val request = ImageRequest.Builder(context)
.data(targetUrl)
.allowHardware(false)
.size(1080)
.build()

val result = context.imageLoader.execute(request)
if (result is SuccessResult) {
(result.drawable as? BitmapDrawable)?.bitmap?.let { original ->
// Ensure it's a software bitmap
if (original.config == Bitmap.Config.HARDWARE) {
original.copy(Bitmap.Config.ARGB_8888, false)
} else {
original
}
}
} else null
}.getOrNull()
}
} else null

val blurred = if (loadedBitmap != null) {
runCatching {
FastBlur.blurArtwork(loadedBitmap, radius = 25, maxDimension = 180)
}.getOrElse {
createBrandFallbackGradient(themePrimaryColor)
}
} else {
createBrandFallbackGradient(themePrimaryColor)
}

ArtworkResolution(
coverBitmap = loadedBitmap,
blurredBitmap = blurred,
resolvedUrl = targetUrl,
)
}

/**
* Pure function implementing the exact resolution order:
* 1. track.artworkUrl (if real)
* 2. resolvedArtworkUrl (if real)
* 3. upscaled YouTube URL
*/
fun determineArtworkUrl(trackInfo: TrackInfo): String? {
val raw = trackInfo.artworkUrl
val resolved = trackInfo.resolvedArtworkUrl

// Local files take highest precedence
if (raw?.startsWith("file://") == true || raw?.startsWith("content://") == true) {
return raw
}

val primary = when {
ArtworkNormalizer.isRealImage(raw) -> raw
ArtworkNormalizer.isRealImage(resolved) -> resolved
else -> null
}

return primary?.let { ArtworkNormalizer.upscaleYoutubeArtwork(it) ?: it }
}

/**
* Creates a smooth 1080x1350 brand fallback gradient when no artwork is available.
*/
fun createBrandFallbackGradient(
accentColor: Int,
width: Int = 540,
height: Int = 720,
): Bitmap {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)

val darkBase = ColorContrastHelper.DEFAULT_DARK
val gradient = LinearGradient(
0f, 0f, width.toFloat(), height.toFloat(),
intArrayOf(accentColor, darkBase, 0xFF080B0E.toInt()),
floatArrayOf(0.0f, 0.55f, 1.0f),
Shader.TileMode.CLAMP,
)
paint.shader = gradient
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
return bitmap
}
}
Loading
Loading