diff --git a/CHANGELOG.md b/CHANGELOG.md index b12a82633c..d7f2e02b70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/com/lastwave/app/ui/player/ModernLyricsView.kt b/app/src/main/java/com/lastwave/app/ui/player/ModernLyricsView.kt index 36e9930569..7c103a375f 100644 --- a/app/src/main/java/com/lastwave/app/ui/player/ModernLyricsView.kt +++ b/app/src/main/java/com/lastwave/app/ui/player/ModernLyricsView.kt @@ -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 @@ -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. @@ -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, + ) + } + } + } } /** @@ -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) @@ -1421,6 +1467,8 @@ private fun ModernLyricsControlsHost( primaryColor = primaryColor, secondaryColor = secondaryColor, tertiaryColor = tertiaryColor, + canShareLyrics = canShareLyrics, + onShareLyrics = onShareLyrics, modifier = modifier, ) } @@ -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) } @@ -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() diff --git a/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt b/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt index 8d777a72bc..aad87535f7 100644 --- a/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt +++ b/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt @@ -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), diff --git a/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/ArtworkResolver.kt b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/ArtworkResolver.kt new file mode 100644 index 0000000000..0f562d09dd --- /dev/null +++ b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/ArtworkResolver.kt @@ -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 + } +} diff --git a/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/ColorContrastHelper.kt b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/ColorContrastHelper.kt new file mode 100644 index 0000000000..5b9f466b7c --- /dev/null +++ b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/ColorContrastHelper.kt @@ -0,0 +1,284 @@ +package com.lastwave.app.ui.player.lyricsshare + +import android.graphics.Bitmap +import android.graphics.Color as AndroidColor +import androidx.palette.graphics.Palette +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow + +/** + * Metadata for a color swatch available in the Lyrics Share color picker. + */ +data class ColorSwatch( + val id: String, + val nameRes: Int, + val color: Int, // ARGB packed integer + val isCoverArt: Boolean = false, +) + +/** + * Contrast-evaluated text colors for a background swatch. + */ +data class DerivedCardColors( + val backgroundColor: Int, + val lyricsTextColor: Int, + val secondaryTextColor: Int, + val brandTextColor: Int, + val dividerColor: Int = 0x33FFFFFF.toInt(), + val isDarkBackground: Boolean = true, +) + +object ColorContrastHelper { + + const val SWATCH_COVER_ART: Int = 0x00C0BEA7 // Sentinel ARGB for Cover Art background swatch + const val BRAND_LIME: Int = 0xFFC9FB00.toInt() + const val DEFAULT_DARK: Int = 0xFF12161A.toInt() + const val PURE_WHITE: Int = 0xFFFFFFFF.toInt() + const val PURE_BLACK: Int = 0xFF101010.toInt() + + /** + * Calculates the WCAG 2.1 relative luminance of an sRGB color. + */ + fun relativeLuminance(color: Int): Double { + val r = linearize(AndroidColor.red(color) / 255.0) + val g = linearize(AndroidColor.green(color) / 255.0) + val b = linearize(AndroidColor.blue(color) / 255.0) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } + + private fun linearize(channel: Double): Double { + return if (channel <= 0.04045) { + channel / 12.92 + } else { + ((channel + 0.055) / 1.055).pow(2.4) + } + } + + /** + * Calculates the WCAG 2.1 contrast ratio between two colors (ranging from 1.0 to 21.0). + */ + fun contrastRatio(fgColor: Int, bgColor: Int): Double { + val l1 = relativeLuminance(fgColor) + val l2 = relativeLuminance(bgColor) + val lighter = max(l1, l2) + val darker = min(l1, l2) + return (lighter + 0.05) / (darker + 0.05) + } + + /** + * Iteratively adjusts [fgColor] brightness/saturation until it satisfies [minRatio] contrast against [bgColor]. + */ + fun ensureMinContrast( + fgColor: Int, + bgColor: Int, + minRatio: Double, + preferLight: Boolean = true, + ): Int { + var current = fgColor + if (contrastRatio(current, bgColor) >= minRatio) return current + + val hsv = FloatArray(3) + AndroidColor.colorToHSV(current, hsv) + + var step = 0 + while (contrastRatio(current, bgColor) < minRatio && step < 20) { + if (preferLight) { + hsv[1] = (hsv[1] * 0.85f).coerceAtLeast(0f) + hsv[2] = (hsv[2] + 0.05f).coerceAtMost(1.0f) + } else { + hsv[2] = (hsv[2] - 0.05f).coerceAtLeast(0f) + } + current = AndroidColor.HSVToColor(0xFF, hsv) + step++ + } + + // Guaranteed absolute fallback if needed + return if (contrastRatio(current, bgColor) >= minRatio) { + current + } else if (preferLight) { + 0xFFFFFFFF.toInt() + } else { + 0xFF000000.toInt() + } + } + + /** + * Derives rich YT Music style card background and high-contrast foreground text colors: + * - Background: rich, deep, saturated version of the selected swatch (or flat color). + * - Text: light tint of the same hue (e.g. warm peach/cream on deep brown). + * - Contrast guarantee: >= 7:1 for lyrics, >= 4.5:1 for title/artist/brand. + */ + fun deriveTextColors(backgroundColor: Int, isCoverScrim: Boolean = false): DerivedCardColors { + if (isCoverScrim || backgroundColor == SWATCH_COVER_ART) { + // Full card cover art with 50% dark scrim + gradient: effective bg is very dark (~0.05 luminance) + return DerivedCardColors( + backgroundColor = 0xFF14171A.toInt(), + lyricsTextColor = 0xFFFFFFFF.toInt(), + secondaryTextColor = 0xD9FFFFFF.toInt(), // ~85% white + brandTextColor = 0xE6FFFFFF.toInt(), // ~90% white + dividerColor = 0x33FFFFFF.toInt(), + isDarkBackground = true, + ) + } + + val bgLum = relativeLuminance(backgroundColor) + val hsv = FloatArray(3) + AndroidColor.colorToHSV(backgroundColor, hsv) + val hue = hsv[0] + val sat = hsv[1] + val value = hsv[2] + + // 1. Pure White / very light backgrounds + if (bgLum > 0.80 || (sat < 0.08f && value > 0.90f)) { + val bg = 0xFFFFFFFF.toInt() + val lyricsColor = ensureMinContrast(0xFF101418.toInt(), bg, 7.0, preferLight = false) + val secColor = ensureMinContrast(0xFF475569.toInt(), bg, 4.5, preferLight = false) + val brandColor = ensureMinContrast(0xFF1E293B.toInt(), bg, 4.5, preferLight = false) + return DerivedCardColors( + backgroundColor = bg, + lyricsTextColor = lyricsColor, + secondaryTextColor = secColor, + brandTextColor = brandColor, + dividerColor = 0x26000000.toInt(), + isDarkBackground = false, + ) + } + + // 2. Pure Black / near-black backgrounds + if (bgLum < 0.02 && sat < 0.15f) { + val bg = 0xFF101214.toInt() + val lyricsColor = ensureMinContrast(0xFFF9FAFB.toInt(), bg, 7.0, preferLight = true) + val secColor = ensureMinContrast(0xFFA0AEC0.toInt(), bg, 4.5, preferLight = true) + val brandColor = ensureMinContrast(0xFFE2E8F0.toInt(), bg, 4.5, preferLight = true) + return DerivedCardColors( + backgroundColor = bg, + lyricsTextColor = lyricsColor, + secondaryTextColor = secColor, + brandTextColor = brandColor, + dividerColor = 0x33FFFFFF.toInt(), + isDarkBackground = true, + ) + } + + // 3. Rich Saturated Colors (YouTube Music style: deep saturated background + same-hue tinted text) + val richBgSat = (sat * 1.15f).coerceIn(0.65f, 1.0f) + val richBgVal = if (value > 0.70f) 0.22f else value.coerceIn(0.18f, 0.28f) + val richBg = AndroidColor.HSVToColor(0xFF, floatArrayOf(hue, richBgSat, richBgVal)) + + // Same-hue subtle light tint for lyrics + val tintSat = (sat * 0.16f).coerceIn(0.08f, 0.20f) + val tintVal = 0.98f + val rawLyrics = AndroidColor.HSVToColor(0xFF, floatArrayOf(hue, tintSat, tintVal)) + val lyricsColor = ensureMinContrast(rawLyrics, richBg, 7.0, preferLight = true) + + // Secondary text: same hue, slightly softer tint + val secSat = (sat * 0.22f).coerceIn(0.12f, 0.26f) + val secVal = 0.82f + val rawSec = AndroidColor.HSVToColor(0xFF, floatArrayOf(hue, secSat, secVal)) + val secondaryColor = ensureMinContrast(rawSec, richBg, 4.5, preferLight = true) + + // Brand lockup: bold tint + val brandSat = (sat * 0.18f).coerceIn(0.10f, 0.22f) + val brandVal = 0.90f + val rawBrand = AndroidColor.HSVToColor(0xFF, floatArrayOf(hue, brandSat, brandVal)) + val brandColor = ensureMinContrast(rawBrand, richBg, 4.5, preferLight = true) + + val dividerColor = AndroidColor.argb( + 50, + AndroidColor.red(secondaryColor), + AndroidColor.green(secondaryColor), + AndroidColor.blue(secondaryColor), + ) + + return DerivedCardColors( + backgroundColor = richBg, + lyricsTextColor = lyricsColor, + secondaryTextColor = secondaryColor, + brandTextColor = brandColor, + dividerColor = dividerColor, + isDarkBackground = true, + ) + } + + /** + * Returns a pair of top and bottom gradient colors for the Select Lyrics screen, + * maintaining high saturation and depth of the same hue without fading to black or grey. + */ + fun getSelectScreenGradientColors(primaryColor: Int): Pair { + val hsv = FloatArray(3) + AndroidColor.colorToHSV(primaryColor, hsv) + val hue = if (hsv[1] < 0.12f) 75f else hsv[0] // fallback to brand lime hue if input is grey + val sat = (hsv[1] * 1.35f).coerceIn(0.70f, 1.0f) + val topVal = 0.35f + val bottomVal = 0.50f + val top = AndroidColor.HSVToColor(0xFF, floatArrayOf(hue, sat, topVal)) + val bottom = AndroidColor.HSVToColor(0xFF, floatArrayOf(hue, sat, bottomVal)) + return Pair(top, bottom) + } + + /** + * Derives a dark, rich hue-tinted text color for the solid white selected line pill. + */ + fun getSelectedPillTextColor(primaryColor: Int): Int { + val hsv = FloatArray(3) + AndroidColor.colorToHSV(primaryColor, hsv) + val hue = if (hsv[1] < 0.12f) 75f else hsv[0] + val sat = 0.95f + val value = 0.22f + return AndroidColor.HSVToColor(0xFF, floatArrayOf(hue, sat, value)) + } + + /** + * Blends an alpha foreground color over an opaque background to get an effective RGB. + */ + fun blendOver(fgColor: Int, bgColor: Int): Int { + val alpha = AndroidColor.alpha(fgColor) / 255.0 + val r = (AndroidColor.red(fgColor) * alpha + AndroidColor.red(bgColor) * (1.0 - alpha)).toInt().coerceIn(0, 255) + val g = (AndroidColor.green(fgColor) * alpha + AndroidColor.green(bgColor) * (1.0 - alpha)).toInt().coerceIn(0, 255) + val b = (AndroidColor.blue(fgColor) * alpha + AndroidColor.blue(bgColor) * (1.0 - alpha)).toInt().coerceIn(0, 255) + return AndroidColor.rgb(r, g, b) + } + + /** + * Extracts palette colors from a software bitmap and combines them with theme tokens. + * When [artBitmap] is present, prepends the Cover swatch as the FIRST item in the list. + */ + fun extractSwatches( + artBitmap: Bitmap?, + themeAccentColor: Int = 0xFF38BDF8.toInt(), + ): List { + val palette = artBitmap?.let { + runCatching { Palette.from(it).clearFilters().generate() }.getOrNull() + } + + val vibrant = palette?.vibrantSwatch?.rgb + ?: palette?.lightVibrantSwatch?.rgb + ?: palette?.dominantSwatch?.rgb + ?: 0xFF2563EB.toInt() + + val muted = palette?.mutedSwatch?.rgb + ?: palette?.darkMutedSwatch?.rgb + ?: 0xFF64748B.toInt() + + val dominant = palette?.dominantSwatch?.rgb + ?: 0xFF1E293B.toInt() + + val darkVariant = palette?.darkVibrantSwatch?.rgb + ?: palette?.darkMutedSwatch?.rgb + ?: 0xFF0F172A.toInt() + + val list = mutableListOf() + + list.add(ColorSwatch("song_vibrant", com.lastwave.app.R.string.lyshare_color_song, vibrant)) + list.add(ColorSwatch("muted", com.lastwave.app.R.string.lyshare_color_muted, muted)) + list.add(ColorSwatch("dominant", com.lastwave.app.R.string.lyshare_color_dominant, dominant)) + list.add(ColorSwatch("dark", com.lastwave.app.R.string.lyshare_color_dark, darkVariant)) + list.add(ColorSwatch("white", com.lastwave.app.R.string.lyshare_color_white, PURE_WHITE)) + list.add(ColorSwatch("black", com.lastwave.app.R.string.lyshare_color_black, PURE_BLACK)) + list.add(ColorSwatch("accent", com.lastwave.app.R.string.lyshare_color_accent, themeAccentColor)) + list.add(ColorSwatch("brand", com.lastwave.app.R.string.lyshare_color_brand, BRAND_LIME)) + + return list + } +} diff --git a/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/LyricsSnapshot.kt b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/LyricsSnapshot.kt new file mode 100644 index 0000000000..da740c7676 --- /dev/null +++ b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/LyricsSnapshot.kt @@ -0,0 +1,163 @@ +package com.lastwave.app.ui.player.lyricsshare + +import com.lastwave.app.data.lyrics.LyricLine +import com.lastwave.app.data.lyrics.isRtlText +import com.lastwave.app.ui.player.LyricsUiState + +/** + * Immutable metadata of the track at the moment the share flow opened. + */ +data class TrackInfo( + val title: String, + val artist: String, + val artworkUrl: String? = null, + val resolvedArtworkUrl: String? = null, +) + +/** + * A selectable line of lyrics with directional metadata. + */ +data class ShareLine( + val text: String, + val isRtl: Boolean = isRtlText(text), + val originalIndex: Int = -1, +) + +/** + * Point-in-time immutable snapshot of lyrics and track info for the share sheet. + * Playback keeps advancing underneath, but this snapshot remains stable. + */ +data class LyricsSnapshot( + val trackInfo: TrackInfo, + val lines: List, + val initialActiveLineIndex: Int = -1, +) + +/** + * Creates an immutable snapshot from current [LyricsUiState.Success] lines. + * Filters out blank lines and maps raw text only (never offset-shifted timestamps). + * Supports both line-synced / word-synced and plain unsynced lyrics. + */ +fun buildLyricsSnapshot( + lyricsState: LyricsUiState, + trackInfo: TrackInfo, + currentActiveIndex: Int = -1, +): LyricsSnapshot? { + val success = lyricsState as? LyricsUiState.Success ?: return null + if (success.isInstrumental) return null + + val shareLines: List = if (success.isSynced && success.lines.isNotEmpty()) { + success.lines.mapIndexedNotNull { index, line -> + val trimmed = line.text.trim() + if (trimmed.isNotBlank()) { + ShareLine( + text = trimmed, + isRtl = line.isRtl || isRtlText(trimmed), + originalIndex = index, + ) + } else null + } + } else { + // Plain (unsynced) lyrics fallback: split on newlines, discarding empty lines + val plain = success.plainLyrics ?: return null + plain.lines() + .map { it.trim() } + .filter { it.isNotBlank() } + .mapIndexed { index, text -> + ShareLine( + text = text, + isRtl = isRtlText(text), + originalIndex = index, + ) + } + } + + if (shareLines.isEmpty()) return null + + // Map active index to filtered lines list + val mappedActiveIndex = if (currentActiveIndex >= 0 && success.isSynced) { + val found = shareLines.indexOfFirst { it.originalIndex >= currentActiveIndex } + if (found >= 0) found else shareLines.lastIndex + } else { + -1 + } + + return LyricsSnapshot( + trackInfo = trackInfo, + lines = shareLines, + initialActiveLineIndex = mappedActiveIndex, + ) +} + +/** + * Computes default 1..3 line selection window centered on the active line. + * If there is no active line yet, starts at the top (first 3 lines). + */ +fun computeDefaultSelection(totalLines: Int, activeIndex: Int): IntRange { + if (totalLines <= 0) return IntRange.EMPTY + if (totalLines <= 3) return 0 until totalLines + + return if (activeIndex in 0 until totalLines) { + val idealStart = (activeIndex - 1).coerceAtLeast(0) + val idealEnd = (idealStart + 2).coerceAtMost(totalLines - 1) + val finalStart = (idealEnd - 2).coerceAtLeast(0) + finalStart..idealEnd + } else { + 0..minOf(2, totalLines - 1) + } +} + +/** + * Implements contiguous selection rules for 1 to 5 lines: + * - Tapping a line adjacent to the range extends it (clamped at [maxLines]). + * - Tapping an edge line shrinks it (minimum 1 line). + * - Tapping an inner line restarts selection from that single line. + * - Tapping a non-adjacent line restarts selection from that single line. + * - At [maxLines], invokes [onCapReached] without extending. + */ +fun updateContiguousSelection( + currentRange: IntRange, + tappedIndex: Int, + totalLines: Int, + maxLines: Int = 5, + onCapReached: () -> Unit = {}, +): IntRange { + if (tappedIndex !in 0 until totalLines) return currentRange + if (currentRange.isEmpty()) return tappedIndex..tappedIndex + + val start = currentRange.first + val end = currentRange.last + val currentCount = end - start + 1 + + return when { + // Tapping edge of a multi-line range: shrink + tappedIndex == start && currentCount > 1 -> (start + 1)..end + tappedIndex == end && currentCount > 1 -> start..(end - 1) + + // Tapping single selected line: keep it (minimum 1) + tappedIndex == start && currentCount == 1 -> currentRange + + // Tapping immediately adjacent before + tappedIndex == start - 1 -> { + if (currentCount >= maxLines) { + onCapReached() + currentRange + } else { + tappedIndex..end + } + } + + // Tapping immediately adjacent after + tappedIndex == end + 1 -> { + if (currentCount >= maxLines) { + onCapReached() + currentRange + } else { + start..tappedIndex + } + } + + // Any non-adjacent or interior tap: restart single-line selection + else -> tappedIndex..tappedIndex + } +} diff --git a/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/SelectLyricsScreen.kt b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/SelectLyricsScreen.kt new file mode 100644 index 0000000000..13644e409e --- /dev/null +++ b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/SelectLyricsScreen.kt @@ -0,0 +1,295 @@ +package com.lastwave.app.ui.player.lyricsshare + +import android.widget.Toast +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lastwave.app.R + +@Composable +fun SelectLyricsScreen( + snapshot: LyricsSnapshot, + selectedRange: IntRange, + onSelectionChange: (IntRange) -> Unit, + onContinue: () -> Unit, + onClose: () -> Unit, + primaryColor: Color = MaterialTheme.colorScheme.primary, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val haptics = LocalHapticFeedback.current + val listState = rememberLazyListState() + + // Auto-scroll so initial selection is in view + LaunchedEffect(Unit) { + if (!selectedRange.isEmpty()) { + val targetIndex = (selectedRange.first - 1).coerceAtLeast(0) + listState.animateScrollToItem(targetIndex) + } + } + + val selectedCount = if (selectedRange.isEmpty()) 0 else (selectedRange.last - selectedRange.first + 1) + val maxLinesCapMessage = stringResource(R.string.lyshare_max_lines_reached) + + // Rich, vivid Spotify-style vertical gradient of the SAME hue (deep top to slightly lighter bottom, no muddy fade) + val (topColorInt, bottomColorInt) = remember(primaryColor) { + ColorContrastHelper.getSelectScreenGradientColors(primaryColor.toArgb()) + } + val backgroundBrush = remember(topColorInt, bottomColorInt) { + Brush.verticalGradient( + colors = listOf( + Color(topColorInt), + Color(bottomColorInt), + ), + ) + } + + // Dark tinted text color for inside the solid white selected pill + val darkPillTextColor = remember(primaryColor) { + Color(ColorContrastHelper.getSelectedPillTextColor(primaryColor.toArgb())) + } + + Box( + modifier = modifier + .fillMaxSize() + .background(backgroundBrush), + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + // Header with centered title/subtitle and safe insets + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onClose, + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.lyshare_close_cd), + tint = Color.White, + ) + } + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.lyshare_select_lyrics), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + Text( + text = if (selectedCount == 1) { + stringResource(R.string.lyshare_line_selected_single) + } else { + stringResource(R.string.lyshare_lines_selected, selectedCount) + }, + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.70f), + ) + } + + // Balance the back button for true screen centering + Spacer(modifier = Modifier.size(48.dp)) + } + + // Lyrics List + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + itemsIndexed(snapshot.lines, key = { index, _ -> index }) { index, line -> + val isSelected = index in selectedRange + val canExtend = selectedCount in 1..4 + val isExtendableNeighbor = canExtend && (index == selectedRange.first - 1 || index == selectedRange.last + 1) + + // Solid white pill for selected lines, subtle 16% alpha pill for extendable neighbors, transparent for unselected + val targetBgColor = when { + isSelected -> Color.White + isExtendableNeighbor -> Color.White.copy(alpha = 0.16f) + else -> Color.Transparent + } + val rowBgColor by animateColorAsState( + targetValue = targetBgColor, + animationSpec = tween(180), + label = "lyricSelectionBg", + ) + + // Dark tinted text on white pill, crisp white for unselected & neighbor lines + val targetTextColor = when { + isSelected -> darkPillTextColor + else -> Color.White + } + val rowTextColor by animateColorAsState( + targetValue = targetTextColor, + animationSpec = tween(180), + label = "lyricSelectionText", + ) + + val accessibilityLabel = if (isSelected) { + stringResource(R.string.lyshare_accessibility_line_selected, index + 1, snapshot.lines.size) + } else { + stringResource(R.string.lyshare_accessibility_line_unselected, index + 1, snapshot.lines.size) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(26.dp)) + .background(rowBgColor) + .clickable { + val next = updateContiguousSelection( + currentRange = selectedRange, + tappedIndex = index, + totalLines = snapshot.lines.size, + maxLines = 5, + onCapReached = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + Toast.makeText(context, maxLinesCapMessage, Toast.LENGTH_SHORT).show() + }, + ) + onSelectionChange(next) + } + .semantics { + role = Role.Checkbox + selected = isSelected + contentDescription = "$accessibilityLabel: ${line.text}" + } + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Text( + text = line.text, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 21.sp, + lineHeight = 27.sp, + letterSpacing = (-0.2).sp, + ), + fontWeight = FontWeight.Bold, + color = rowTextColor, + textAlign = if (line.isRtl) TextAlign.End else TextAlign.Start, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + item { + // Generous bottom spacer so lyrics can scroll smoothly behind the Continue pill + Spacer(modifier = Modifier.height(110.dp)) + } + } + } + + // Soft gradient fade at bottom edge so lyrics fade out behind Continue button + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(130.dp) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color(bottomColorInt).copy(alpha = 0.85f), + Color(bottomColorInt), + ), + ), + ), + ) + + // Compact white pill Continue button + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 18.dp), + contentAlignment = Alignment.Center, + ) { + Button( + onClick = onContinue, + enabled = selectedCount > 0, + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color.Black, + disabledContainerColor = Color.White.copy(alpha = 0.25f), + disabledContentColor = Color.Black.copy(alpha = 0.40f), + ), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp), + modifier = Modifier + .width(152.dp) + .height(50.dp), + ) { + Text( + text = stringResource(R.string.lyshare_continue), + style = MaterialTheme.typography.titleMedium.copy(fontSize = 16.sp), + fontWeight = FontWeight.Bold, + ) + } + } + } +} diff --git a/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/ShareCardRenderer.kt b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/ShareCardRenderer.kt new file mode 100644 index 0000000000..dec1e21a13 --- /dev/null +++ b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/ShareCardRenderer.kt @@ -0,0 +1,625 @@ +package com.lastwave.app.ui.player.lyricsshare + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color as AndroidColor +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.PorterDuffXfermode +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.Typeface +import android.os.Build +import android.text.Layout +import android.text.StaticLayout +import android.text.TextPaint +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import com.lastwave.app.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.math.max +import kotlin.math.roundToInt + +object ShareCardRenderer { + + const val BASE_WIDTH: Int = 1080 + const val MIN_HEIGHT: Int = 1080 // 1:1 square aspect ratio minimum (no big empty areas) + const val MAX_HEIGHT: Int = 1920 // 9:16 vertical story aspect ratio maximum + + const val LYRIC_LINE_SPACING_MULTIPLIER: Float = 1.18f + const val LYRIC_BLOCK_GAP_FACTOR: Float = 0.45f + + const val BASE_LYRIC_FONT_SIZE: Float = 84f + const val MIN_LYRIC_FONT_SIZE: Float = 52f + + const val BASE_TITLE_FONT_SIZE: Float = 46f + const val BASE_ARTIST_FONT_SIZE: Float = 40f + + const val BASE_SONG_LYRIC_FONT_SIZE: Float = 64f + const val MIN_SONG_LYRIC_FONT_SIZE: Float = 44f + + /** + * Renders the card into an immutable software [Bitmap] with automatic OOM retry. + */ + suspend fun renderCard( + context: Context, + spec: ShareCardSpec, + ): Bitmap = withContext(Dispatchers.Default) { + try { + renderInternal(context, spec) + } catch (oom: OutOfMemoryError) { + System.gc() + val retrySpec = spec.copy(scaleFactor = spec.scaleFactor * 0.75f) + renderInternal(context, retrySpec) + } + } + + private fun renderInternal( + context: Context, + spec: ShareCardSpec, + ): Bitmap { + val scale = spec.scaleFactor + val width = (spec.targetWidth * scale).roundToInt().coerceAtLeast(320) + val minH = (width * 1.0f).roundToInt() // 1:1 minimum + val maxH = (width * 16.0f / 9.0f).roundToInt() // 9:16 maximum + + val hPadding = (60 * scale).roundToInt() + val contentWidth = width - (hPadding * 2) + + // Measure required height and calculate fitted font size + val layoutPlan = measureLayoutPlan(context, spec, contentWidth, scale, maxH) + val height = layoutPlan.requiredHeight.coerceIn(minH, maxH) + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + + if (spec.mode == CardMode.Song) { + renderSongCard(canvas, context, spec, width, height, hPadding, contentWidth, scale, layoutPlan) + } else { + renderLyricsCard(canvas, context, spec, width, height, hPadding, contentWidth, scale, layoutPlan) + } + + return bitmap + } + + fun getTypeface(context: Context, weight: Int): Typeface { + return try { + val base = ResourcesCompat.getFont(context, R.font.google_sans_flex) + if (base != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + Typeface.create(base, weight, false) + } else { + base ?: Typeface.create(Typeface.DEFAULT, if (weight >= 600) Typeface.BOLD else Typeface.NORMAL) + } + } catch (_: Throwable) { + Typeface.create(Typeface.DEFAULT, if (weight >= 600) Typeface.BOLD else Typeface.NORMAL) + } + } + + fun wrapTextToWidth( + text: CharSequence, + paint: TextPaint, + maxWidth: Int, + ): CharSequence { + if (maxWidth <= 0) return text + val rawLines = text.split("\n") + val result = mutableListOf() + + for (rawLine in rawLines) { + if (paint.measureText(rawLine) <= maxWidth) { + result.add(rawLine) + } else { + val words = rawLine.split(" ") + if (words.size <= 1) { + result.add(rawLine) + } else { + var currentLine = StringBuilder() + for (word in words) { + val candidate = if (currentLine.isEmpty()) word else "$currentLine $word" + if (paint.measureText(candidate) <= maxWidth) { + currentLine = StringBuilder(candidate) + } else { + if (currentLine.isNotEmpty()) { + result.add(currentLine.toString()) + currentLine = StringBuilder(word) + } else { + result.add(word) + } + } + } + if (currentLine.isNotEmpty()) { + result.add(currentLine.toString()) + } + } + } + } + return result.joinToString("\n") + } + + fun createStaticLayout( + text: CharSequence, + paint: TextPaint, + width: Int, + isRtl: Boolean, + spacingMult: Float = LYRIC_LINE_SPACING_MULTIPLIER, + ): StaticLayout { + val alignment = if (isRtl) Layout.Alignment.ALIGN_OPPOSITE else Layout.Alignment.ALIGN_NORMAL + val safeWidth = max(width, 10) + val wrappedText = wrapTextToWidth(text, paint, safeWidth) + return try { + StaticLayout.Builder.obtain(wrappedText, 0, wrappedText.length, paint, safeWidth).let { builder -> + builder.setAlignment(alignment) + .setLineSpacing(0f, spacingMult) + .setIncludePad(true) // Crucial for Hindi/Devanagari matras, Arabic marks, and ascenders + .build() + } + } catch (_: Throwable) { + @Suppress("DEPRECATION") + StaticLayout(wrappedText, 0, wrappedText.length, paint, safeWidth, alignment, spacingMult, 0f, true) + } + } + + private data class LayoutPlan( + val fontSize: Float, + val lyricLayouts: List, + val lyricsBlockHeight: Int, + val requiredHeight: Int, + val headerHeight: Int = 0, + val titleLayout: StaticLayout? = null, + val artistLayout: StaticLayout? = null, + ) + + private fun measureLayoutPlan( + context: Context, + spec: ShareCardSpec, + contentWidth: Int, + scale: Float, + maxAllowedHeight: Int, + ): LayoutPlan { + val boldTypeface = getTypeface(context, 700) + val regularTypeface = getTypeface(context, 450) + + val topPadding = (60 * scale).roundToInt() + val bottomPadding = (60 * scale).roundToInt() + val footerHeight = (64 * scale).roundToInt() + + if (spec.mode == CardMode.Lyrics) { + // Lyrics Card: Header has small cover thumbnail (180px) on left + Title & Artist on right + val thumbSize = (180 * scale).roundToInt() + val textGap = (24 * scale).roundToInt() + val textWidth = (contentWidth - thumbSize - textGap).coerceAtLeast(100) + + val titlePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = boldTypeface + textSize = BASE_TITLE_FONT_SIZE * scale + color = spec.derivedColors.lyricsTextColor + } + val titleLayout = createStaticLayout(spec.title, titlePaint, textWidth, spec.isRtl, 1.15f) + + val artistPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = regularTypeface + textSize = BASE_ARTIST_FONT_SIZE * scale + color = spec.derivedColors.secondaryTextColor + } + val artistLayout = createStaticLayout(spec.artist, artistPaint, textWidth, spec.isRtl, 1.15f) + + val textBlockHeight = titleLayout.height + (8 * scale).roundToInt() + artistLayout.height + val headerHeight = max(thumbSize, textBlockHeight) + + val dividerSpace = (26 * scale).roundToInt() + (32 * scale).roundToInt() + val footerGap = (84 * scale).roundToInt() + + var fontSize = BASE_LYRIC_FONT_SIZE * scale + val floor = MIN_LYRIC_FONT_SIZE * scale + + var bestPlan: LayoutPlan? = null + + while (fontSize >= floor) { + val lyricPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = boldTypeface + textSize = fontSize + color = spec.derivedColors.lyricsTextColor + } + + val layouts = spec.lines.map { createStaticLayout(it.text, lyricPaint, contentWidth, it.isRtl) } + val blockGap = fontSize * LYRIC_BLOCK_GAP_FACTOR + val lyricsHeight = (layouts.sumOf { it.height.toDouble() } + (layouts.size - 1).coerceAtLeast(0) * blockGap).roundToInt() + + val totalReq = topPadding + headerHeight + dividerSpace + lyricsHeight + footerGap + footerHeight + bottomPadding + + val plan = LayoutPlan( + fontSize = fontSize, + lyricLayouts = layouts, + lyricsBlockHeight = lyricsHeight, + requiredHeight = totalReq, + headerHeight = headerHeight, + titleLayout = titleLayout, + artistLayout = artistLayout, + ) + + if (bestPlan == null || totalReq <= maxAllowedHeight) { + bestPlan = plan + } + if (totalReq <= maxAllowedHeight) { + return plan + } + + fontSize -= (2f * scale) + } + + return bestPlan!! + } else { + // Song Card: Hero Cover Art (680px) on top left, title, artist, divider, lyrics, brand footer + val heroSize = (680 * scale).roundToInt() + val heroToTitle = (28 * scale).roundToInt() + + val titlePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = boldTypeface + textSize = BASE_TITLE_FONT_SIZE * scale + color = spec.derivedColors.lyricsTextColor + } + val titleLayout = createStaticLayout(spec.title, titlePaint, contentWidth, spec.isRtl, 1.15f) + + val artistPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = regularTypeface + textSize = BASE_ARTIST_FONT_SIZE * scale + color = spec.derivedColors.secondaryTextColor + } + val artistLayout = createStaticLayout(spec.artist, artistPaint, contentWidth, spec.isRtl, 1.15f) + + val textBlockHeight = titleLayout.height + (8 * scale).roundToInt() + artistLayout.height + val dividerSpace = (24 * scale).roundToInt() + (28 * scale).roundToInt() + val footerGap = (72 * scale).roundToInt() + + var fontSize = BASE_SONG_LYRIC_FONT_SIZE * scale + val floor = MIN_SONG_LYRIC_FONT_SIZE * scale + + var bestPlan: LayoutPlan? = null + + while (fontSize >= floor) { + val lyricPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = boldTypeface + textSize = fontSize + color = spec.derivedColors.lyricsTextColor + } + + val layouts = spec.lines.map { createStaticLayout(it.text, lyricPaint, contentWidth, it.isRtl) } + val blockGap = fontSize * LYRIC_BLOCK_GAP_FACTOR + val lyricsHeight = (layouts.sumOf { it.height.toDouble() } + (layouts.size - 1).coerceAtLeast(0) * blockGap).roundToInt() + + val totalReq = topPadding + heroSize + heroToTitle + textBlockHeight + dividerSpace + lyricsHeight + footerGap + footerHeight + bottomPadding + + val plan = LayoutPlan( + fontSize = fontSize, + lyricLayouts = layouts, + lyricsBlockHeight = lyricsHeight, + requiredHeight = totalReq, + headerHeight = heroSize, + titleLayout = titleLayout, + artistLayout = artistLayout, + ) + + if (bestPlan == null || totalReq <= maxAllowedHeight) { + bestPlan = plan + } + if (totalReq <= maxAllowedHeight) { + return plan + } + + fontSize -= (2f * scale) + } + + return bestPlan!! + } + } + + // ========================================== + // LYRICS CARD RENDERING (YT Music & Spotify style) + // ========================================== + private fun renderLyricsCard( + canvas: Canvas, + context: Context, + spec: ShareCardSpec, + width: Int, + height: Int, + hPadding: Int, + contentWidth: Int, + scale: Float, + plan: LayoutPlan, + ) { + // 1. Background + if (spec.isCoverBackground && spec.coverBitmap != null && !spec.coverBitmap.isRecycled) { + // Full-bleed center-cropped cover art with ~50% dark scrim + gradient protection + drawCenterCropCover(canvas, spec.coverBitmap, width, height) + canvas.drawColor(0x80000000.toInt()) // ~50% black scrim + + val scrimGrad = Paint(Paint.ANTI_ALIAS_FLAG).apply { + shader = LinearGradient( + 0f, 0f, 0f, height.toFloat(), + intArrayOf(0x66000000, 0x10000000, 0x88000000.toInt()), + floatArrayOf(0.0f, 0.40f, 1.0f), + Shader.TileMode.CLAMP, + ) + } + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), scrimGrad) + } else { + // Rich flat / subtle same-hue gradient (YT style - no muddy blur) + val topColor = spec.derivedColors.backgroundColor or 0xFF000000.toInt() + val hsv = FloatArray(3) + AndroidColor.colorToHSV(topColor, hsv) + hsv[2] = (hsv[2] * 0.82f).coerceAtLeast(0.08f) // slightly deeper shade at bottom + val bottomColor = AndroidColor.HSVToColor(0xFF, hsv) or 0xFF000000.toInt() + + canvas.drawColor(topColor) // Explicit opaque fill + val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + shader = LinearGradient( + 0f, 0f, 0f, height.toFloat(), + intArrayOf(topColor, bottomColor), + null, + Shader.TileMode.CLAMP, + ) + } + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint) + } + + var currentY = (60 * scale) + + // 2. Header: Rounded Cover Thumbnail (left) + Title and Artist (right) + val thumbSize = (180 * scale).roundToInt() + val thumbRadius = 22f * scale + val textGap = (24 * scale).roundToInt() + val textX = (hPadding + thumbSize + textGap).toFloat() + + if (spec.coverBitmap != null && !spec.coverBitmap.isRecycled) { + val roundedThumb = getRoundedBitmap(spec.coverBitmap, thumbRadius, thumbSize, thumbSize) + canvas.drawBitmap(roundedThumb, hPadding.toFloat(), currentY, null) + } else { + // Fallback thumbnail with subtle gradient and brand mark + val thumbRect = RectF(hPadding.toFloat(), currentY, (hPadding + thumbSize).toFloat(), currentY + thumbSize) + val thumbBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = AndroidColor.argb(40, 255, 255, 255) + } + canvas.drawRoundRect(thumbRect, thumbRadius, thumbRadius, thumbBgPaint) + drawLogoMark(canvas, context, thumbRect.centerX(), thumbRect.centerY(), (48 * scale).roundToInt(), spec.derivedColors.lyricsTextColor) + } + + // Title and Artist alongside thumbnail + val titleLayout = plan.titleLayout + val artistLayout = plan.artistLayout + if (titleLayout != null && artistLayout != null) { + val textBlockHeight = titleLayout.height + (8 * scale).roundToInt() + artistLayout.height + val textOffsetY = if (thumbSize > textBlockHeight) (thumbSize - textBlockHeight) / 2f else 0f + val textStartY = currentY + textOffsetY + + canvas.save() + canvas.translate(textX, textStartY) + titleLayout.draw(canvas) + canvas.restore() + + canvas.save() + canvas.translate(textX, textStartY + titleLayout.height + (8 * scale)) + artistLayout.draw(canvas) + canvas.restore() + } + + currentY += plan.headerHeight + (26 * scale) + + // 3. Thin divider line + val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = spec.derivedColors.dividerColor + strokeWidth = 2f * scale + } + canvas.drawLine(hPadding.toFloat(), currentY, (width - hPadding).toFloat(), currentY, dividerPaint) + currentY += (32 * scale) + + // 4. Large bold tinted lyrics (word wrapped naturally, tight line spacing, generous block gap) + val blockGap = plan.fontSize * LYRIC_BLOCK_GAP_FACTOR + + // Calculate available vertical breathing room if card is clamped to 1:1 square + val footerAreaH = (64 * scale) + (60 * scale) + val remainingSpace = height - currentY - plan.lyricsBlockHeight - footerAreaH + if (remainingSpace > (40 * scale)) { + currentY += (remainingSpace * 0.35f) // visually center lyrics slightly if tall square + } + + for (layout in plan.lyricLayouts) { + canvas.save() + canvas.translate(hPadding.toFloat(), currentY) + layout.draw(canvas) + canvas.restore() + currentY += layout.height + blockGap + } + + // 5. Brand footer: bottom-left aligned to hPadding! + drawBrandFooterLeft(canvas, context, hPadding, height, scale, spec.derivedColors.brandTextColor) + } + + // ========================================== + // SONG CARD RENDERING (Hero Cover, left-aligned) + // ========================================== + private fun renderSongCard( + canvas: Canvas, + context: Context, + spec: ShareCardSpec, + width: Int, + height: Int, + hPadding: Int, + contentWidth: Int, + scale: Float, + plan: LayoutPlan, + ) { + // 1. Background: rich flat / subtle gradient of selected swatch (NO muddy blur!) + val topColor = spec.derivedColors.backgroundColor or 0xFF000000.toInt() + val hsv = FloatArray(3) + AndroidColor.colorToHSV(topColor, hsv) + hsv[2] = (hsv[2] * 0.82f).coerceAtLeast(0.08f) + val bottomColor = AndroidColor.HSVToColor(0xFF, hsv) or 0xFF000000.toInt() + + canvas.drawColor(topColor) // Explicit opaque fill + val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + shader = LinearGradient( + 0f, 0f, 0f, height.toFloat(), + intArrayOf(topColor, bottomColor), + null, + Shader.TileMode.CLAMP, + ) + } + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint) + + var currentY = (60 * scale) + + // 2. Hero Cover Art: large (~65% width), left-aligned to hPadding (no stray centering, no dark bottom strip!) + val heroSize = plan.headerHeight + val cornerRadius = 32f * scale + val heroRect = RectF(hPadding.toFloat(), currentY, (hPadding + heroSize).toFloat(), currentY + heroSize) + + if (spec.coverBitmap != null && !spec.coverBitmap.isRecycled) { + val roundedHero = getRoundedBitmap(spec.coverBitmap, cornerRadius, heroSize, heroSize) + canvas.drawBitmap(roundedHero, heroRect.left, heroRect.top, null) + } else { + // Placeholder hero card + val placeholderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = AndroidColor.argb(40, 255, 255, 255) + } + canvas.drawRoundRect(heroRect, cornerRadius, cornerRadius, placeholderPaint) + drawLogoMark(canvas, context, heroRect.centerX(), heroRect.centerY(), (80 * scale).roundToInt(), spec.derivedColors.lyricsTextColor) + } + + currentY += heroSize + (28 * scale) + + // 3. Title and Artist + val titleLayout = plan.titleLayout + if (titleLayout != null) { + canvas.save() + canvas.translate(hPadding.toFloat(), currentY) + titleLayout.draw(canvas) + canvas.restore() + currentY += titleLayout.height + (8 * scale) + } + + val artistLayout = plan.artistLayout + if (artistLayout != null) { + canvas.save() + canvas.translate(hPadding.toFloat(), currentY) + artistLayout.draw(canvas) + canvas.restore() + currentY += artistLayout.height + (24 * scale) + } + + // 4. Thin divider line + val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = spec.derivedColors.dividerColor + strokeWidth = 2f * scale + } + canvas.drawLine(hPadding.toFloat(), currentY, (width - hPadding).toFloat(), currentY, dividerPaint) + currentY += (28 * scale) + + // 5. Hero Lyrics (auto wrapped, generous block gap) + val blockGap = plan.fontSize * LYRIC_BLOCK_GAP_FACTOR + + for (layout in plan.lyricLayouts) { + canvas.save() + canvas.translate(hPadding.toFloat(), currentY) + layout.draw(canvas) + canvas.restore() + currentY += layout.height + blockGap + } + + // 6. Brand footer: bottom-left aligned to hPadding! + drawBrandFooterLeft(canvas, context, hPadding, height, scale, spec.derivedColors.brandTextColor) + } + + /** + * Draws the brand lockup on the BOTTOM-LEFT aligned with the same left margin as the lyrics and header. + * Lockup occupies ~35-40% of card width. + */ + private fun drawBrandFooterLeft( + canvas: Canvas, + context: Context, + x: Int, + height: Int, + scale: Float, + brandColor: Int, + ) { + val footerY = height - (60 * scale) + val logoSize = (64 * scale).roundToInt() + val gap = (14 * scale) + + val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = getTypeface(context, 750) + textSize = 64f * scale + color = brandColor + } + + // Draw tintable logo mark + drawLogoMark(canvas, context, x + (logoSize / 2f), footerY - (logoSize / 2f), logoSize, brandColor) + + // Draw "LastWave" text aligned horizontally with logo + val brandName = "LastWave" + val textX = x + logoSize + gap + val textY = footerY - (logoSize * 0.18f) + canvas.drawText(brandName, textX, textY, textPaint) + } + + private fun drawLogoMark( + canvas: Canvas, + context: Context, + centerX: Float, + centerY: Float, + size: Int, + tintColor: Int, + ) { + val drawable = runCatching { + ContextCompat.getDrawable(context, R.drawable.ic_lastwave_logo_mono) + }.getOrNull() ?: return + val half = size / 2 + drawable.setBounds((centerX - half).toInt(), (centerY - half).toInt(), (centerX + half).toInt(), (centerY + half).toInt()) + drawable.colorFilter = PorterDuffColorFilter(tintColor, PorterDuff.Mode.SRC_IN) + drawable.draw(canvas) + } + + private fun drawCenterCropCover(canvas: Canvas, source: Bitmap, targetW: Int, targetH: Int) { + val srcW = source.width + val srcH = source.height + + val srcRatio = srcW.toFloat() / srcH.toFloat() + val targetRatio = targetW.toFloat() / targetH.toFloat() + + val cropW: Int + val cropH: Int + if (srcRatio > targetRatio) { + cropH = srcH + cropW = (srcH * targetRatio).roundToInt() + } else { + cropW = srcW + cropH = (srcW / targetRatio).roundToInt() + } + + val cropX = (srcW - cropW) / 2 + val cropY = (srcH - cropH) / 2 + + val srcRect = Rect(cropX, cropY, cropX + cropW, cropY + cropH) + val dstRect = Rect(0, 0, targetW, targetH) + val paint = Paint(Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG) + canvas.drawBitmap(source, srcRect, dstRect, paint) + } + + private fun getRoundedBitmap(source: Bitmap, cornerRadius: Float, targetW: Int, targetH: Int): Bitmap { + val output = Bitmap.createBitmap(targetW, targetH, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + val rect = RectF(0f, 0f, targetW.toFloat(), targetH.toFloat()) + + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) + + val srcRect = Rect(0, 0, source.width, source.height) + val dstRect = Rect(0, 0, targetW, targetH) + canvas.drawBitmap(source, srcRect, dstRect, paint) + return output + } +} diff --git a/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/ShareCardSpec.kt b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/ShareCardSpec.kt new file mode 100644 index 0000000000..5cf6f16da5 --- /dev/null +++ b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/ShareCardSpec.kt @@ -0,0 +1,26 @@ +package com.lastwave.app.ui.player.lyricsshare + +import android.graphics.Bitmap + +enum class CardMode { + Song, + Lyrics, +} + +/** + * Pure data model defining all parameters needed to render a card. + */ +data class ShareCardSpec( + val mode: CardMode = CardMode.Song, + val lines: List, + val title: String, + val artist: String, + val coverBitmap: Bitmap? = null, + val blurredBackgroundBitmap: Bitmap? = null, + val swatchColor: Int = ColorContrastHelper.BRAND_LIME, + val derivedColors: DerivedCardColors = ColorContrastHelper.deriveTextColors(swatchColor), + val isCoverBackground: Boolean = false, + val isRtl: Boolean = false, + val targetWidth: Int = 1080, + val scaleFactor: Float = 1.0f, +) diff --git a/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/ShareLyricsDialog.kt b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/ShareLyricsDialog.kt new file mode 100644 index 0000000000..338bcf715a --- /dev/null +++ b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/ShareLyricsDialog.kt @@ -0,0 +1,172 @@ +package com.lastwave.app.ui.player.lyricsshare + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.lastwave.app.ui.common.EdgeToEdgeDialogWindow +import com.lastwave.app.util.FileExportHelper + +private enum class ShareStep { + Select, + Preview, +} + +@Composable +fun ShareLyricsDialog( + snapshot: LyricsSnapshot, + onDismiss: () -> Unit, + fileExportHelper: FileExportHelper? = null, + primaryColor: Color = MaterialTheme.colorScheme.primary, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val exportHelper = fileExportHelper ?: remember(context) { FileExportHelper(context.applicationContext) } + val totalLines = snapshot.lines.size + + // Default 1..3 selection window + val initialSelection = remember(snapshot) { + computeDefaultSelection(totalLines, snapshot.initialActiveLineIndex) + } + + var currentStepName by rememberSaveable { mutableStateOf(ShareStep.Select.name) } + var selectedStart by rememberSaveable { mutableIntStateOf(initialSelection.first) } + var selectedEnd by rememberSaveable { mutableIntStateOf(initialSelection.last) } + var cardModeName by rememberSaveable { mutableStateOf(CardMode.Song.name) } + + val currentStep = if (currentStepName == ShareStep.Preview.name) ShareStep.Preview else ShareStep.Select + val currentRange = if (selectedStart in 0 until totalLines && selectedEnd in 0 until totalLines && selectedStart <= selectedEnd) { + selectedStart..selectedEnd + } else { + initialSelection + } + val currentMode = if (cardModeName == CardMode.Lyrics.name) CardMode.Lyrics else CardMode.Song + + // Resolve artwork asynchronously off main thread + var artworkResolution by remember { + mutableStateOf( + ArtworkResolution( + coverBitmap = null, + blurredBitmap = ArtworkResolver.createBrandFallbackGradient(primaryColor.toArgb()), + resolvedUrl = null, + ) + ) + } + + LaunchedEffect(snapshot.trackInfo) { + val result = ArtworkResolver.resolveArtwork(context, snapshot.trackInfo, primaryColor.toArgb()) + artworkResolution = result + } + + // Extract swatches based on artwork & theme accent + val swatches = remember(artworkResolution.coverBitmap, primaryColor) { + ColorContrastHelper.extractSwatches(artworkResolution.coverBitmap, primaryColor.toArgb()) + } + + val defaultSwatchColor = remember(swatches) { + swatches.firstOrNull()?.color ?: ColorContrastHelper.BRAND_LIME + } + + var selectedSwatchColor by rememberSaveable { mutableIntStateOf(defaultSwatchColor) } + + // Keep swatch valid if palette updates + LaunchedEffect(swatches) { + if (swatches.none { it.color == selectedSwatchColor }) { + selectedSwatchColor = defaultSwatchColor + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + EdgeToEdgeDialogWindow() + + BackHandler { + if (currentStep == ShareStep.Preview) { + currentStepName = ShareStep.Select.name + } else { + onDismiss() + } + } + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black), + ) { + AnimatedContent( + targetState = currentStep, + transitionSpec = { + if (targetState == ShareStep.Preview) { + (slideInHorizontally(tween(250)) { it } + fadeIn(tween(200))) + .togetherWith(slideOutHorizontally(tween(250)) { -it / 3 } + fadeOut(tween(150))) + } else { + (slideInHorizontally(tween(250)) { -it / 3 } + fadeIn(tween(200))) + .togetherWith(slideOutHorizontally(tween(250)) { it } + fadeOut(tween(150))) + } + }, + label = "shareStepTransition", + ) { step -> + when (step) { + ShareStep.Select -> { + SelectLyricsScreen( + snapshot = snapshot, + selectedRange = currentRange, + onSelectionChange = { newRange -> + selectedStart = newRange.first + selectedEnd = newRange.last + }, + onContinue = { + currentStepName = ShareStep.Preview.name + }, + onClose = onDismiss, + primaryColor = Color(selectedSwatchColor), + ) + } + + ShareStep.Preview -> { + SharePreviewScreen( + snapshot = snapshot, + selectedRange = currentRange, + artworkResolution = artworkResolution, + swatches = swatches, + selectedMode = currentMode, + onModeChange = { cardModeName = it.name }, + selectedSwatchColor = selectedSwatchColor, + onSwatchChange = { selectedSwatchColor = it }, + onBack = { currentStepName = ShareStep.Select.name }, + onClose = onDismiss, + fileExportHelper = exportHelper, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/SharePreviewScreen.kt b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/SharePreviewScreen.kt new file mode 100644 index 0000000000..6a129e47ec --- /dev/null +++ b/app/src/main/java/com/lastwave/app/ui/player/lyricsshare/SharePreviewScreen.kt @@ -0,0 +1,486 @@ +package com.lastwave.app.ui.player.lyricsshare + +import android.graphics.Bitmap +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Bookmark +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lastwave.app.R +import com.lastwave.app.ui.common.ExpressiveInlineLoadingIndicator +import com.lastwave.app.util.FileExportHelper +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun SharePreviewScreen( + snapshot: LyricsSnapshot, + selectedRange: IntRange, + artworkResolution: ArtworkResolution, + swatches: List, + selectedMode: CardMode, + onModeChange: (CardMode) -> Unit, + selectedSwatchColor: Int, + onSwatchChange: (Int) -> Unit, + onBack: () -> Unit, + onClose: () -> Unit, + fileExportHelper: FileExportHelper, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val selectedLines = remember(snapshot.lines, selectedRange) { + if (selectedRange.isEmpty()) emptyList() + else snapshot.lines.slice(selectedRange.first..selectedRange.last) + } + + val isCoverSelected = selectedSwatchColor == ColorContrastHelper.SWATCH_COVER_ART && selectedMode == CardMode.Lyrics + + // Build specification for renderer + val spec = remember( + selectedMode, + selectedLines, + snapshot.trackInfo, + artworkResolution, + selectedSwatchColor, + isCoverSelected, + ) { + val derived = ColorContrastHelper.deriveTextColors(selectedSwatchColor, isCoverScrim = isCoverSelected) + val hasRtl = selectedLines.any { it.isRtl } + ShareCardSpec( + mode = selectedMode, + lines = selectedLines, + title = snapshot.trackInfo.title, + artist = snapshot.trackInfo.artist, + coverBitmap = artworkResolution.coverBitmap, + blurredBackgroundBitmap = artworkResolution.blurredBitmap, + swatchColor = selectedSwatchColor, + derivedColors = derived, + isCoverBackground = isCoverSelected, + isRtl = hasRtl, + targetWidth = ShareCardRenderer.BASE_WIDTH, + scaleFactor = 0.50f, // Scaled for fast live preview + ) + } + + // Auto-revert cover swatch to color swatch if user switches to Song mode + LaunchedEffect(selectedMode) { + if (selectedMode == CardMode.Song && selectedSwatchColor == ColorContrastHelper.SWATCH_COVER_ART) { + val fallback = swatches.firstOrNull { !it.isCoverArt }?.color ?: ColorContrastHelper.BRAND_LIME + onSwatchChange(fallback) + } + } + + var previewBitmap by remember { mutableStateOf(null) } + var isPreviewRendering by remember { mutableStateOf(true) } + var renderJob by remember { mutableStateOf(null) } + + // Re-render preview with ~100ms debounce when spec changes + LaunchedEffect(spec) { + renderJob?.cancel() + renderJob = launch { + isPreviewRendering = true + delay(100L) + val bmp = ShareCardRenderer.renderCard(context, spec) + previewBitmap = bmp + isPreviewRendering = false + } + } + + // Export states + var isExporting by remember { mutableStateOf(false) } + + val savedToGalleryMsg = stringResource(R.string.lyshare_saved_to_gallery) + val saveFailedMsg = stringResource(R.string.lyshare_save_failed) + val shareFailedMsg = stringResource(R.string.lyshare_share_failed) + + fun onShareClick() { + if (isExporting) return + isExporting = true + scope.launch { + try { + // Render full 1080px scale bitmap for export + val fullSpec = spec.copy(scaleFactor = 1.0f) + val fullBitmap = ShareCardRenderer.renderCard(context, fullSpec) + val res = fileExportHelper.shareImage(context, fullBitmap, snapshot.trackInfo.title) + if (res.isFailure) { + Toast.makeText(context, shareFailedMsg, Toast.LENGTH_SHORT).show() + } + } catch (_: Exception) { + Toast.makeText(context, shareFailedMsg, Toast.LENGTH_SHORT).show() + } finally { + isExporting = false + } + } + } + + fun onSaveClick() { + if (isExporting) return + isExporting = true + scope.launch { + try { + val fullSpec = spec.copy(scaleFactor = 1.0f) + val fullBitmap = ShareCardRenderer.renderCard(context, fullSpec) + val res = fileExportHelper.saveImageToGallery(fullBitmap, snapshot.trackInfo.title) + if (res.isSuccess) { + Toast.makeText(context, savedToGalleryMsg, Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, saveFailedMsg, Toast.LENGTH_SHORT).show() + } + } catch (_: Exception) { + Toast.makeText(context, saveFailedMsg, Toast.LENGTH_SHORT).show() + } finally { + isExporting = false + } + } + } + + Box( + modifier = modifier + .fillMaxSize() + .background(Color(0xFF090C0E)), + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + // Top Bar + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + IconButton( + onClick = onBack, + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.lyshare_back_cd), + tint = Color.White, + ) + } + + Text( + text = stringResource(R.string.lyshare_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + + IconButton( + onClick = onClose, + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.lyshare_close_cd), + tint = Color.White, + ) + } + } + + // Live Card Preview Area + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + val bmp = previewBitmap + if (bmp != null) { + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = stringResource(R.string.lyshare_title), + modifier = Modifier + .fillMaxHeight(0.96f) + .shadow(16.dp, RoundedCornerShape(20.dp), clip = false) + .clip(RoundedCornerShape(20.dp)), + ) + } else { + CircularProgressIndicator( + color = Color.White, + modifier = Modifier.size(36.dp), + ) + } + + // Loading overlay while re-rendering on mode/color changes + androidx.compose.animation.AnimatedVisibility( + visible = isPreviewRendering && previewBitmap != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier + .size(40.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape), + contentAlignment = Alignment.Center, + ) { + ExpressiveInlineLoadingIndicator(size = 20.dp, color = Color.White) + } + } + } + + // Segmented Card Mode Toggle ("Song" | "Lyrics") + Row( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 8.dp) + .background(Color.White.copy(alpha = 0.12f), CircleShape) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + CardModeTab( + label = stringResource(R.string.lyshare_mode_song), + selected = selectedMode == CardMode.Song, + onClick = { onModeChange(CardMode.Song) }, + ) + CardModeTab( + label = stringResource(R.string.lyshare_mode_lyrics), + selected = selectedMode == CardMode.Lyrics, + onClick = { onModeChange(CardMode.Lyrics) }, + ) + } + + val displaySwatches = remember(swatches, selectedMode, artworkResolution.coverBitmap) { + if (selectedMode == CardMode.Lyrics && artworkResolution.coverBitmap != null) { + listOf( + ColorSwatch( + id = "cover_art", + nameRes = R.string.lyshare_color_cover, + color = ColorContrastHelper.SWATCH_COVER_ART, + isCoverArt = true, + ) + ) + swatches.filter { !it.isCoverArt } + } else { + swatches.filter { !it.isCoverArt } + } + } + + // YT Music Style Color Swatches Picker (with Cover swatch as first item in Lyrics mode) + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .semantics { contentDescription = context.getString(R.string.lyshare_color_picker_cd) }, + contentPadding = PaddingValues(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + items(displaySwatches, key = { it.id }) { swatch -> + val isSelected = swatch.color == selectedSwatchColor + val swatchName = stringResource(swatch.nameRes) + + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .then( + if (swatch.isCoverArt) { + Modifier + } else { + Modifier.background(Color(swatch.color)) + } + ) + .then( + if (isSelected) { + Modifier.border(BorderStroke(3.dp, Color.White), CircleShape) + } else { + Modifier.border(BorderStroke(1.dp, Color.White.copy(alpha = 0.25f)), CircleShape) + } + ) + .clickable { onSwatchChange(swatch.color) } + .semantics { + role = Role.RadioButton + selected = isSelected + contentDescription = swatchName + }, + contentAlignment = Alignment.Center, + ) { + if (swatch.isCoverArt && artworkResolution.coverBitmap != null) { + Image( + bitmap = artworkResolution.coverBitmap.asImageBitmap(), + contentDescription = null, + contentScale = androidx.compose.ui.layout.ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + ) + } + if (isSelected) { + Box( + modifier = Modifier + .size(10.dp) + .background(Color.White, CircleShape), + ) + } + } + } + } + + // Bottom Actions: Secondary Save + Primary White Pill Share + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Secondary Save button + IconButton( + onClick = { onSaveClick() }, + enabled = !isExporting, + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.14f)), + ) { + Icon( + imageVector = Icons.Rounded.Download, + contentDescription = stringResource(R.string.lyshare_action_save), + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + + // Primary Share button + Button( + onClick = { onShareClick() }, + enabled = !isExporting, + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color.Black, + disabledContainerColor = Color.White.copy(alpha = 0.35f), + disabledContentColor = Color.Black.copy(alpha = 0.50f), + ), + modifier = Modifier + .weight(1f) + .height(52.dp), + ) { + if (isExporting) { + ExpressiveInlineLoadingIndicator(size = 20.dp, color = Color.Black) + } else { + Icon( + imageVector = Icons.Rounded.Share, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.lyshare_action_share), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } +} + +@Composable +private fun CardModeTab( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + val bg by animateColorAsState( + targetValue = if (selected) Color.White else Color.Transparent, + animationSpec = androidx.compose.animation.core.tween(150), + label = "modeTabBg", + ) + val fg by animateColorAsState( + targetValue = if (selected) Color.Black else Color.White.copy(alpha = 0.70f), + animationSpec = androidx.compose.animation.core.tween(150), + label = "modeTabFg", + ) + + Box( + modifier = Modifier + .clip(CircleShape) + .background(bg) + .clickable(onClick = onClick) + .padding(horizontal = 22.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 14.sp), + fontWeight = FontWeight.Bold, + color = fg, + ) + } +} diff --git a/app/src/main/java/com/lastwave/app/util/FileExportHelper.kt b/app/src/main/java/com/lastwave/app/util/FileExportHelper.kt index 13bac8426b..f546f570d4 100644 --- a/app/src/main/java/com/lastwave/app/util/FileExportHelper.kt +++ b/app/src/main/java/com/lastwave/app/util/FileExportHelper.kt @@ -9,6 +9,10 @@ import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.app.Activity +import android.content.ClipData +import android.content.ContextWrapper +import android.graphics.Bitmap import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import com.lastwave.app.data.generate.GeneratedTrack @@ -162,6 +166,139 @@ class FileExportHelper @Inject constructor( context.startActivity(Intent.createChooser(intent, "Share").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } + /** + * Unwraps [Context] hierarchy to find an [Activity] context for launching share dialogs. + */ + private fun findActivity(context: Context): Activity? { + var ctx = context + while (ctx is ContextWrapper) { + if (ctx is Activity) return ctx + ctx = ctx.baseContext + } + return null + } + + /** + * Cache hygiene for share cards: purges files older than 24h and enforces a 10-file cap. + */ + fun cleanLyricsCardCache(cacheDir: File) { + val dir = File(cacheDir, "shared/lyrics_cards") + if (!dir.exists()) return + val files = dir.listFiles() ?: return + val now = System.currentTimeMillis() + val maxAge = 24 * 60 * 60 * 1000L + + for (file in files) { + if (now - file.lastModified() > maxAge) { + file.delete() + } + } + + val remaining = dir.listFiles() ?: return + if (remaining.size > 10) { + val sorted = remaining.sortedBy { it.lastModified() } + val excess = remaining.size - 10 + for (i in 0 until excess) { + sorted[i].delete() + } + } + } + + /** + * Exports a rendered bitmap to cacheDir/shared/lyrics_cards and triggers the system + * share sheet via FileProvider with image/png and ClipData uri grant. + */ + suspend fun shareImage( + activityContext: Context, + bitmap: Bitmap, + title: String, + ): Result = withContext(Dispatchers.IO) { + try { + cleanLyricsCardCache(context.cacheDir) + val dir = File(context.cacheDir, "shared/lyrics_cards") + if (!dir.exists()) dir.mkdirs() + + val sanitized = sanitizeFilename(title) + val timestamp = System.currentTimeMillis() + val file = File(dir, "lyrics_${sanitized}_$timestamp.png") + + file.outputStream().use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + out.flush() + } + + val authority = "${context.packageName}.provider" + val uri = FileProvider.getUriForFile(context, authority, file) + + val intent = Intent(Intent.ACTION_SEND).apply { + type = "image/png" + putExtra(Intent.EXTRA_STREAM, uri) + clipData = ClipData.newRawUri("Lyrics Card", uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + val chooser = Intent.createChooser(intent, "Share lyrics").apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + val activity = findActivity(activityContext) + if (activity != null) { + activity.startActivity(chooser) + } else { + context.startActivity(chooser) + } + + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Saves a rendered card bitmap to Pictures/LastWave via MediaStore.Images + * with IS_PENDING handling for API 29+. + */ + suspend fun saveImageToGallery( + bitmap: Bitmap, + title: String, + ): Result = withContext(Dispatchers.IO) { + try { + val resolver = context.contentResolver + val sanitized = sanitizeFilename(title) + val filename = "lyrics_${sanitized}_${System.currentTimeMillis()}.png" + val relativePath = "${Environment.DIRECTORY_PICTURES}/LastWave" + + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, filename) + put(MediaStore.Images.Media.MIME_TYPE, "image/png") + put(MediaStore.Images.Media.RELATIVE_PATH, relativePath) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Images.Media.IS_PENDING, 1) + } + } + + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + ?: throw IOException("Failed to create MediaStore entry for $filename") + + resolver.openOutputStream(uri)?.use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + out.flush() + } ?: throw IOException("Failed to open output stream for $uri") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val updateValues = ContentValues().apply { + put(MediaStore.Images.Media.IS_PENDING, 0) + } + resolver.update(uri, updateValues, null, null) + } + + Result.success(uri) + } catch (e: Exception) { + Result.failure(e) + } + } + fun sanitizeFilename(title: String): String = title.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim().ifBlank { "playlist" } diff --git a/app/src/main/res/drawable/ic_lastwave_logo_mono.xml b/app/src/main/res/drawable/ic_lastwave_logo_mono.xml new file mode 100644 index 0000000000..aec5603849 --- /dev/null +++ b/app/src/main/res/drawable/ic_lastwave_logo_mono.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index ff4b815084..ad1898c053 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1,4 +1,4 @@ - + LastWave مشغّل LastWave @@ -1035,4 +1035,34 @@ تم تخطي ملف %1$d (تنسيق غير مدعوم: %2$s) تم تخطي ملفات %1$d (تنسيق غير مدعوم: %2$s) + + + مشاركة الكلمات + تحديد الكلمات + تم تحديد %1$d أسطر + تم تحديد سطر واحد + يمكنك مشاركة ما يصل إلى 5 أسطر + متابعة + أغنية + كلمات + مشاركة + حفظ + تم الحفظ في Pictures/LastWave + تعذر حفظ الصورة + تعذر مشاركة الصورة + مشاركة الكلمات + إغلاق + رجوع + لوحة الألوان + الغلاف + لون الأغنية + خافت + سائد + داكن + أبيض + أسود + لون مميز + ليموني LastWave + محدد، السطر %1$d من %2$d + السطر %1$d من %2$d diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 43dfb45444..59f68a0a8f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,4 +1,4 @@ - + LastWave LastWave Player @@ -1035,4 +1035,34 @@ %1$d-Datei übersprungen (nicht unterstütztes Format: %2$s) %1$d Dateien übersprungen (nicht unterstütztes Format: %2$s) + + + Songtext teilen + Songtext auswählen + %1$d Zeilen ausgewählt + 1 Zeile ausgewählt + Du kannst bis zu 5 Zeilen teilen + Weiter + Titel + Songtext + Teilen + Speichern + In Bilder/LastWave gespeichert + Bild konnte nicht gespeichert werden + Bild konnte nicht geteilt werden + Songtext teilen + Schließen + Zurück + Farbpalette + Cover + Titelfarbe + Dezente Farbe + Dominante Farbe + Dunkel + Weiß + Schwarz + Akzent + LastWave Limette + Ausgewählt, Zeile %1$d von %2$d + Zeile %1$d von %2$d diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e5e1bdf22c..60d5c8411a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,4 +1,4 @@ - + LastWave Reproductor LastWave @@ -1035,4 +1035,34 @@ Archivo %1$d omitido (formato no compatible: %2$s) %1$d archivos omitidos (formato no compatible: %2$s) + + + Compartir letras + Seleccionar letras + %1$d líneas seleccionadas + 1 línea seleccionada + Puedes compartir hasta 5 líneas + Continuar + Canción + Letras + Compartir + Guardar + Guardado en Imágenes/LastWave + No se pudo guardar la imagen + No se pudo compartir la imagen + Compartir letras + Cerrar + Atrás + Paleta de colores + Portada + Color de la canción + Apagado + Dominante + Oscuro + Blanco + Negro + Acento + Lima LastWave + Seleccionada, línea %1$d de %2$d + Línea %1$d de %2$d diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 429339f682..ee6e153939 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,4 +1,4 @@ - + LastWave Lecteur LastWave @@ -1038,4 +1038,34 @@ Le mode Invité fonctionne entièrement hors ligne avec des recommandations locales. Connectez Last.fm à tout moment dans Paramètres pour synchroniser les scrobbles. Restaurer la sauvegarde Ouvrir les téléchargements hors ligne + + + Partager les paroles + Sélectionner les paroles + %1$d lignes sélectionnées + 1 ligne sélectionnée + Vous pouvez partager jusqu\'à 5 lignes + Continuer + Titre + Paroles + Partager + Enregistrer + Enregistré dans Images/LastWave + Impossible d\'enregistrer l\'image + Impossible de partager l\'image + Partager les paroles + Fermer + Retour + Palette de couleurs + Pochette + Couleur du morceau + Atténuée + Dominante + Sombre + Blanc + Noir + Accent + Citron vert LastWave + Sélectionnée, ligne %1$d sur %2$d + Ligne %1$d sur %2$d diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index c1449c6a8f..7140e2de29 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1,4 +1,4 @@ - + LastWave LastWave प्लेयर @@ -1035,4 +1035,34 @@ %1$d फ़ाइल छोड़ दी गई (असमर्थित प्रारूप: %2$s) %1$d फ़ाइलें छोड़ दी गईं (असमर्थित प्रारूप: %2$s) + + + बोल साझा करें + बोल चुनें + %1$d पंक्तियाँ चुनी गईं + 1 पंक्ति चुनी गई + आप अधिकतम 5 पंक्तियाँ साझा कर सकते हैं + आगे बढ़ें + गाना + बोल + साझा करें + सहेजें + Pictures/LastWave में सहेजा गया + छवि सहेजी नहीं जा सकी + छवि साझा नहीं की जा सकी + बोल साझा करें + बंद करें + वापस + रंग पैलेट + कवर + गाने का रंग + हल्का + प्रमुख + गहरा + सफ़ेद + काला + एक्सेंट + लास्टवेव लाइम + चयनित, पंक्ति %1$d / %2$d + पंक्ति %1$d / %2$d diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 65d48c53cd..f516e11b43 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1,4 +1,4 @@ - + LastWave Pemutar LastWave @@ -1035,4 +1035,34 @@ File %1$d dilewati (format tidak didukung: %2$s) %1$d file dilewati (format tidak didukung: %2$s) + + + Bagikan Lirik + Pilih Lirik + %1$d baris dipilih + 1 baris dipilih + Anda dapat membagikan hingga 5 baris + Lanjutkan + Lagu + Lirik + Bagikan + Simpan + Disimpan ke Pictures/LastWave + Gagal menyimpan gambar + Gagal membagikan gambar + Bagikan lirik + Tutup + Kembali + Palet warna + Sampul + Warna lagu + Lembut + Dominan + Gelap + Putih + Hitam + Aksen + LastWave Lime + Dipilih, baris %1$d dari %2$d + Baris %1$d dari %2$d diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 0746599d1f..02da23c523 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1,4 +1,4 @@ - + LastWave LastWave プレーヤー @@ -1035,4 +1035,34 @@ %1$d ファイルはスキップされました (サポートされていない形式: %2$s) %1$d ファイルがスキップされました (サポートされていない形式: %2$s) + + + 歌詞を共有 + 歌詞を選択 + %1$d 行選択中 + 1 行選択中 + 最大5行まで共有できます + 次へ + 楽曲 + 歌詞 + 共有 + 保存 + Pictures/LastWave に保存しました + 画像を保存できませんでした + 画像を共有できませんでした + 歌詞を共有 + 閉じる + 戻る + カラーパレット + カバー + 楽曲カラー + 落ち着いた色 + メインカラー + ダーク + ホワイト + ブラック + アクセント + LastWave ライム + 選択中、%1$d / %2$d 行目 + %1$d / %2$d 行目 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1205a6907f..961a55f6e5 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1,4 +1,4 @@ - + LastWave LastWave 플레이어 @@ -1035,4 +1035,34 @@ %1$d 파일을 건너뛰었습니다(지원되지 않는 형식: %2$s). %1$d 파일을 건너뛰었습니다(지원되지 않는 형식: %2$s). + + + 가사 공유 + 가사 선택 + %1$d줄 선택됨 + 1줄 선택됨 + 최대 5줄까지 공유할 수 있습니다 + 계속 + 곡 + 가사 + 공유 + 저장 + Pictures/LastWave에 저장되었습니다 + 이미지를 저장하지 못했습니다 + 이미지를 공유하지 못했습니다 + 가사 공유 + 닫기 + 뒤로 + 색상 팔레트 + 커버 + 곡 색상 + 뮤트 + 도미넌트 + 다크 + 화이트 + 블랙 + 강조 색상 + 라스트웨이브 라임 + 선택됨, %2$d줄 중 %1$d번째 줄 + %2$d줄 중 %1$d번째 줄 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 5faf68fabe..fa7205015f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,4 +1,4 @@ - + LastWave LastWave Player @@ -1035,4 +1035,34 @@ Arquivo %1$d ignorado (formato não suportado: %2$s) %1$d arquivos ignorados (formato não suportado: %2$s) + + + Compartilhar letra + Selecionar letra + %1$d linhas selecionadas + 1 linha selecionada + Você pode compartilhar até 5 linhas + Continuar + Música + Letra + Compartilhar + Salvar + Salvo em Pictures/LastWave + Não foi possível salvar a imagem + Não foi possível compartilhar a imagem + Compartilhar letra + Fechar + Voltar + Paleta de cores + Capa + Cor da música + Suave + Dominante + Escuro + Branco + Preto + Destaque + Lima LastWave + Selecionada, linha %1$d de %2$d + Linha %1$d de %2$d diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c68e00d3ac..27e4d3cdcb 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,4 +1,4 @@ - + LastWave LastWave Плеер @@ -1035,4 +1035,34 @@ Файл %1$d пропущен (неподдерживаемый формат: %2$s) Файлы %1$d пропущены (неподдерживаемый формат: %2$s). + + + Поделиться текстом + Выбрать строки + Выбрано строк: %1$d + Выбрана 1 строка + Можно поделиться не более чем 5 строками + Продолжить + Трек + Текст + Поделиться + Сохранить + Сохранено в Pictures/LastWave + Не удалось сохранить изображение + Не удалось поделиться изображением + Поделиться текстом + Закрыть + Назад + Цветовая палитра + Обложка + Цвет трека + Приглушённый + Основной + Тёмный + Белый + Чёрный + Акцент + Лайм LastWave + Выбрана, строка %1$d из %2$d + Строка %1$d из %2$d diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 19334a5423..e161bfe2e6 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1,4 +1,4 @@ - + LastWave LastWave Oynatıcı @@ -1088,4 +1088,34 @@ Bu şarkı sözlerinde zaten Latin karakterleri kullanılıyor Romanizasyon kullanılamıyor Çeviri kullanılamıyor + + + Şarkı sözü paylaş + Sözleri seç + %1$d satır seçildi + 1 satır seçildi + En fazla 5 satır paylaşabilirsiniz + Devam et + Şarkı + Sözler + Paylaş + Kaydet + Pictures/LastWave klasörüne kaydedildi + Görsel kaydedilemedi + Görsel paylaşılamadı + Şarkı sözü paylaş + Kapat + Geri + Renk paleti + Kapak + Şarkı rengi + Sakin + Baskın + Koyu + Beyaz + Siyah + Vurgu + LastWave Limon Yeşili + Seçildi, %1$d / %2$d. satır + %1$d / %2$d. satır diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1dcde7451b..cfd095ef87 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,4 +1,4 @@ - + LastWave LastWave 播放器 @@ -1038,4 +1038,34 @@ 跳过 %1$d 文件(不支持的格式:%2$s) 跳过 %1$d 文件(不支持的格式:%2$s) + + + 分享歌词 + 选择歌词 + 已选 %1$d 行 + 已选 1 行 + 最多可分享 5 行歌词 + 继续 + 歌曲 + 歌词 + 分享 + 保存 + 已保存至 Pictures/LastWave + 保存图片失败 + 分享图片失败 + 分享歌词 + 关闭 + 返回 + 调色盘 + 封面 + 歌曲颜色 + 柔和 + 主色 + 深色 + 白色 + 黑色 + 强调色 + LastWave 青柠 + 已选中,第 %1$d 行(共 %2$d 行) + 第 %1$d 行(共 %2$d 行) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 204006d86a..33ab76155d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1090,4 +1090,34 @@ Guest mode works fully offline with local recommendations. Connect Last.fm anytime in Settings to sync scrobbles. Restore backup Open Offline Downloads + + + Share lyrics + Select lyrics + %1$d lines selected + 1 line selected + You can share up to 5 lines + Continue + Song + Lyrics + Share + Save + Saved to Pictures/LastWave + Couldn\'t save image + Couldn\'t share image + Share lyrics + Close + Back + Color palette + Cover + Song color + Muted + Dominant + Dark + White + Black + Accent + LastWave Lime + Selected, line %1$d of %2$d + Line %1$d of %2$d diff --git a/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/ArtworkResolutionOrderTest.kt b/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/ArtworkResolutionOrderTest.kt new file mode 100644 index 0000000000..c8d4a39041 --- /dev/null +++ b/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/ArtworkResolutionOrderTest.kt @@ -0,0 +1,83 @@ +package com.lastwave.app.ui.player.lyricsshare + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ArtworkResolutionOrderTest { + + @Test + fun determineArtworkUrl_localFile_takesPrecedence() { + val track = TrackInfo( + title = "Local Song", + artist = "Local Artist", + artworkUrl = "file:///storage/emulated/0/Music/cover.jpg", + resolvedArtworkUrl = "https://i.ytimg.com/vi/123/hqdefault.jpg", + ) + val url = ArtworkResolver.determineArtworkUrl(track) + assertThat(url).isEqualTo("file:///storage/emulated/0/Music/cover.jpg") + } + + @Test + fun determineArtworkUrl_trackArtworkUrl_takesPrecedenceOverResolved() { + val track = TrackInfo( + title = "Online Song", + artist = "Artist", + artworkUrl = "https://lh3.googleusercontent.com/track_art=w544-h544", + resolvedArtworkUrl = "https://lh3.googleusercontent.com/ambient_art=w544-h544", + ) + val url = ArtworkResolver.determineArtworkUrl(track) + assertThat(url).contains("track_art") + } + + @Test + fun determineArtworkUrl_resolvedAmbientUsedWhenTrackUrlIsBlankOrPlaceholder() { + val track = TrackInfo( + title = "Online Song", + artist = "Artist", + artworkUrl = "", // Blank + resolvedArtworkUrl = "https://lh3.googleusercontent.com/ambient_art=w544-h544", + ) + val url = ArtworkResolver.determineArtworkUrl(track) + assertThat(url).contains("ambient_art") + } + + @Test + fun determineArtworkUrl_upscalesYouTubeUrls() { + val track = TrackInfo( + title = "YouTube Song", + artist = "Artist", + artworkUrl = "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", + resolvedArtworkUrl = null, + ) + val url = ArtworkResolver.determineArtworkUrl(track) + // ArtworkNormalizer upscales hqdefault to maxresdefault + assertThat(url).contains("maxresdefault.jpg") + } + + @Test + fun determineArtworkUrl_returnsNullWhenNoValidArtwork() { + val track = TrackInfo( + title = "No Art", + artist = "Artist", + artworkUrl = null, + resolvedArtworkUrl = null, + ) + val url = ArtworkResolver.determineArtworkUrl(track) + assertThat(url).isNull() + } + + @Test + fun createBrandFallbackGradient_returnsValidBitmap() { + val gradient = ArtworkResolver.createBrandFallbackGradient( + accentColor = ColorContrastHelper.BRAND_LIME, + width = 100, + height = 100, + ) + assertThat(gradient).isNotNull() + assertThat(gradient.width).isEqualTo(100) + assertThat(gradient.height).isEqualTo(100) + } +} diff --git a/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/CacheHygieneTest.kt b/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/CacheHygieneTest.kt new file mode 100644 index 0000000000..8c3047d430 --- /dev/null +++ b/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/CacheHygieneTest.kt @@ -0,0 +1,75 @@ +package com.lastwave.app.ui.player.lyricsshare + +import com.google.common.truth.Truth.assertThat +import com.lastwave.app.util.FileExportHelper +import io.mockk.mockk +import java.io.File +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class CacheHygieneTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private val helper = FileExportHelper(mockk(relaxed = true)) + + @Test + fun cleanLyricsCardCache_purgesFilesOlderThan24Hours() { + val cacheDir = tempFolder.newFolder("cache") + val lyricsDir = File(cacheDir, "shared/lyrics_cards").apply { mkdirs() } + + val oldFile = File(lyricsDir, "lyrics_old.png").apply { + createNewFile() + // Set last modified to 25 hours ago + setLastModified(System.currentTimeMillis() - 25 * 60 * 60 * 1000L) + } + + val freshFile = File(lyricsDir, "lyrics_fresh.png").apply { + createNewFile() + setLastModified(System.currentTimeMillis()) + } + + helper.cleanLyricsCardCache(cacheDir) + + assertThat(oldFile.exists()).isFalse() + assertThat(freshFile.exists()).isTrue() + } + + @Test + fun cleanLyricsCardCache_enforcesTenFileCap_evictingOldestFirst() { + val cacheDir = tempFolder.newFolder("cache") + val lyricsDir = File(cacheDir, "shared/lyrics_cards").apply { mkdirs() } + + val now = System.currentTimeMillis() + val files = (1..15).map { i -> + File(lyricsDir, "lyrics_$i.png").apply { + createNewFile() + setLastModified(now - (16 - i) * 1000L) // file 1 is oldest, 15 is newest + } + } + + helper.cleanLyricsCardCache(cacheDir) + + val remaining = lyricsDir.listFiles() ?: emptyArray() + assertThat(remaining).hasLength(10) + + // Oldest 5 (1..5) should have been deleted + for (i in 1..5) { + assertThat(File(lyricsDir, "lyrics_$i.png").exists()).isFalse() + } + // Newest 10 (6..15) should remain + for (i in 6..15) { + assertThat(File(lyricsDir, "lyrics_$i.png").exists()).isTrue() + } + } + + @Test + fun sanitizeFilename_stripsSpecialCharactersAndEnforcesFallback() { + assertThat(helper.sanitizeFilename("Song / Title: With * Illegal? |")).isEqualTo("Song _ Title_ With _ Illegal_ _Chars_ _") + assertThat(helper.sanitizeFilename("")).isEqualTo("playlist") + assertThat(helper.sanitizeFilename(" ")).isEqualTo("playlist") + assertThat(helper.sanitizeFilename("Clean Song Title")).isEqualTo("Clean Song Title") + } +} diff --git a/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/ColorContrastHelperTest.kt b/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/ColorContrastHelperTest.kt new file mode 100644 index 0000000000..dd4f42fb64 --- /dev/null +++ b/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/ColorContrastHelperTest.kt @@ -0,0 +1,195 @@ +package com.lastwave.app.ui.player.lyricsshare + +import android.graphics.Bitmap +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ColorContrastHelperTest { + + @Test + fun relativeLuminance_pureWhiteAndBlack() { + val whiteLum = ColorContrastHelper.relativeLuminance(ColorContrastHelper.PURE_WHITE) + val blackLum = ColorContrastHelper.relativeLuminance(0xFF000000.toInt()) + + assertThat(whiteLum).isWithin(0.01).of(1.0) + assertThat(blackLum).isWithin(0.001).of(0.0) + } + + @Test + fun contrastRatio_whiteOnBlack_isNearMax21() { + val ratio = ColorContrastHelper.contrastRatio(0xFFFFFFFF.toInt(), 0xFF000000.toInt()) + assertThat(ratio).isAtLeast(20.0) + } + + @Test + fun deriveTextColors_onPureBlack_meetsWcagRequirements() { + val derived = ColorContrastHelper.deriveTextColors(ColorContrastHelper.PURE_BLACK) + + assertThat(derived.isDarkBackground).isTrue() + + // Lyrics contrast must be >= 7.0:1 (Enhanced Contrast) + val lyricsContrast = ColorContrastHelper.contrastRatio(derived.lyricsTextColor, derived.backgroundColor) + assertThat(lyricsContrast).isAtLeast(7.0) + + // Secondary & Brand text contrast must be >= 4.5:1 + val secondaryContrast = ColorContrastHelper.contrastRatio(derived.secondaryTextColor, derived.backgroundColor) + assertThat(secondaryContrast).isAtLeast(4.5) + + val brandContrast = ColorContrastHelper.contrastRatio(derived.brandTextColor, derived.backgroundColor) + assertThat(brandContrast).isAtLeast(4.5) + } + + @Test + fun deriveTextColors_onPureWhite_meetsWcagRequirements() { + val derived = ColorContrastHelper.deriveTextColors(ColorContrastHelper.PURE_WHITE) + + assertThat(derived.isDarkBackground).isFalse() + + val lyricsContrast = ColorContrastHelper.contrastRatio(derived.lyricsTextColor, derived.backgroundColor) + assertThat(lyricsContrast).isAtLeast(7.0) + + val secondaryContrast = ColorContrastHelper.contrastRatio(derived.secondaryTextColor, derived.backgroundColor) + assertThat(secondaryContrast).isAtLeast(4.5) + + val brandContrast = ColorContrastHelper.contrastRatio(derived.brandTextColor, derived.backgroundColor) + assertThat(brandContrast).isAtLeast(4.5) + } + + @Test + fun deriveTextColors_onBrandLime_meetsWcagRequirements() { + val derived = ColorContrastHelper.deriveTextColors(ColorContrastHelper.BRAND_LIME) + + val lyricsContrast = ColorContrastHelper.contrastRatio(derived.lyricsTextColor, derived.backgroundColor) + assertThat(lyricsContrast).isAtLeast(7.0) + + val secondaryContrast = ColorContrastHelper.contrastRatio(derived.secondaryTextColor, derived.backgroundColor) + assertThat(secondaryContrast).isAtLeast(4.5) + + val brandContrast = ColorContrastHelper.contrastRatio(derived.brandTextColor, derived.backgroundColor) + assertThat(brandContrast).isAtLeast(4.5) + } + + @Test + fun deriveTextColors_onDarkSlate_meetsWcagRequirements() { + val darkSlate = 0xFF1E293B.toInt() + val derived = ColorContrastHelper.deriveTextColors(darkSlate) + + val lyricsContrast = ColorContrastHelper.contrastRatio(derived.lyricsTextColor, derived.backgroundColor) + assertThat(lyricsContrast).isAtLeast(7.0) + + val secondaryContrast = ColorContrastHelper.contrastRatio(derived.secondaryTextColor, derived.backgroundColor) + assertThat(secondaryContrast).isAtLeast(4.5) + + val brandContrast = ColorContrastHelper.contrastRatio(derived.brandTextColor, derived.backgroundColor) + assertThat(brandContrast).isAtLeast(4.5) + } + + @Test + fun deriveTextColors_onWarmBrownAndSaturatedColors_meetsWcagRequirements() { + // Deep amber/brown like YouTube Music screenshot + val colors = listOf( + 0xFF78350F.toInt(), // Warm Amber / Brown + 0xFFDC2626.toInt(), // Vibrant Crimson / Red + 0xFF2563EB.toInt(), // Vivid Blue + 0xFF059669.toInt(), // Emerald Green + 0xFF7C3AED.toInt(), // Purple + ) + + for (c in colors) { + val derived = ColorContrastHelper.deriveTextColors(c) + val lyricsContrast = ColorContrastHelper.contrastRatio(derived.lyricsTextColor, derived.backgroundColor) + assertThat(lyricsContrast).isAtLeast(7.0) + + val secondaryContrast = ColorContrastHelper.contrastRatio(derived.secondaryTextColor, derived.backgroundColor) + assertThat(secondaryContrast).isAtLeast(4.5) + + val brandContrast = ColorContrastHelper.contrastRatio(derived.brandTextColor, derived.backgroundColor) + assertThat(brandContrast).isAtLeast(4.5) + } + } + + @Test + fun deriveTextColors_coverScrim_meetsWcagRequirements() { + val derived = ColorContrastHelper.deriveTextColors( + backgroundColor = ColorContrastHelper.SWATCH_COVER_ART, + isCoverScrim = true, + ) + + assertThat(derived.isDarkBackground).isTrue() + + val lyricsContrast = ColorContrastHelper.contrastRatio(derived.lyricsTextColor, derived.backgroundColor) + assertThat(lyricsContrast).isAtLeast(7.0) + + val secondaryContrast = ColorContrastHelper.contrastRatio(derived.secondaryTextColor, derived.backgroundColor) + assertThat(secondaryContrast).isAtLeast(4.5) + + val brandContrast = ColorContrastHelper.contrastRatio(derived.brandTextColor, derived.backgroundColor) + assertThat(brandContrast).isAtLeast(4.5) + } + + @Test + fun getSelectScreenGradientColors_returnsRichHueGradient() { + val red = 0xFFEF4444.toInt() + val (top, bottom) = ColorContrastHelper.getSelectScreenGradientColors(red) + + // Neither top nor bottom should be black or grey + val topLum = ColorContrastHelper.relativeLuminance(top) + val bottomLum = ColorContrastHelper.relativeLuminance(bottom) + + assertThat(topLum).isGreaterThan(0.01) + assertThat(bottomLum).isGreaterThan(topLum) // bottom is slightly lighter shade of the same hue + } + + @Test + fun getSelectedPillTextColor_hasHighContrastOnWhite() { + val red = 0xFFEF4444.toInt() + val textColor = ColorContrastHelper.getSelectedPillTextColor(red) + val contrastOnWhite = ColorContrastHelper.contrastRatio(textColor, ColorContrastHelper.PURE_WHITE) + + assertThat(contrastOnWhite).isAtLeast(7.0) + } + + @Test + fun extractSwatches_alwaysReturns8DistinctSwatches() { + val swatches = ColorContrastHelper.extractSwatches( + artBitmap = null, + themeAccentColor = 0xFF38BDF8.toInt(), + ) + + assertThat(swatches).hasSize(8) + + val ids = swatches.map { it.id } + assertThat(ids).containsExactly( + "song_vibrant", + "muted", + "dominant", + "dark", + "white", + "black", + "accent", + "brand", + ).inOrder() + + val brandSwatch = swatches.first { it.id == "brand" } + assertThat(brandSwatch.color).isEqualTo(ColorContrastHelper.BRAND_LIME) + + val accentSwatch = swatches.first { it.id == "accent" } + assertThat(accentSwatch.color).isEqualTo(0xFF38BDF8.toInt()) + } + + @Test + fun extractSwatches_withBitmap_extractsColorsSafely() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(0xFFFF5722.toInt()) + + val swatches = ColorContrastHelper.extractSwatches( + artBitmap = bitmap, + themeAccentColor = 0xFF38BDF8.toInt(), + ) + + assertThat(swatches).hasSize(8) + } +} diff --git a/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/LyricsSelectionTest.kt b/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/LyricsSelectionTest.kt new file mode 100644 index 0000000000..574b5a9ee2 --- /dev/null +++ b/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/LyricsSelectionTest.kt @@ -0,0 +1,167 @@ +package com.lastwave.app.ui.player.lyricsshare + +import com.google.common.truth.Truth.assertThat +import com.lastwave.app.data.lyrics.LyricLine +import com.lastwave.app.ui.player.LyricsUiState +import org.junit.Test + +class LyricsSelectionTest { + + private val sampleTrack = TrackInfo( + title = "Starboy", + artist = "The Weeknd", + artworkUrl = "https://example.com/art.jpg", + ) + + @Test + fun buildLyricsSnapshot_synced_filtersBlankLines() { + val lines = listOf( + LyricLine(timeMs = 1000L, text = "I'm tryna put you in the worst mood, ah"), + LyricLine(timeMs = 3000L, text = " "), + LyricLine(timeMs = 5000L, text = ""), + LyricLine(timeMs = 7000L, text = "P1 cleaner than your church shoes, ah"), + ) + val state = LyricsUiState.Success( + lines = lines, + isSynced = true, + ) + + val snapshot = buildLyricsSnapshot(state, sampleTrack, currentActiveIndex = 0) + + assertThat(snapshot).isNotNull() + assertThat(snapshot!!.lines).hasSize(2) + assertThat(snapshot.lines[0].text).isEqualTo("I'm tryna put you in the worst mood, ah") + assertThat(snapshot.lines[0].originalIndex).isEqualTo(0) + assertThat(snapshot.lines[1].text).isEqualTo("P1 cleaner than your church shoes, ah") + assertThat(snapshot.lines[1].originalIndex).isEqualTo(3) + } + + @Test + fun buildLyricsSnapshot_plainLyrics_splitsOnNewlinesAndDiscardsEmpty() { + val plain = "First line\n\n \nSecond line\nThird line\n" + val state = LyricsUiState.Success( + lines = emptyList(), + isSynced = false, + plainLyrics = plain, + ) + + val snapshot = buildLyricsSnapshot(state, sampleTrack) + + assertThat(snapshot).isNotNull() + assertThat(snapshot!!.lines).hasSize(3) + assertThat(snapshot.lines.map { it.text }).containsExactly("First line", "Second line", "Third line").inOrder() + } + + @Test + fun buildLyricsSnapshot_instrumental_returnsNull() { + val state = LyricsUiState.Success( + lines = emptyList(), + isSynced = true, + isInstrumental = true, + ) + + val snapshot = buildLyricsSnapshot(state, sampleTrack) + assertThat(snapshot).isNull() + } + + @Test + fun buildLyricsSnapshot_empty_returnsNull() { + val state = LyricsUiState.Success( + lines = emptyList(), + isSynced = true, + ) + + val snapshot = buildLyricsSnapshot(state, sampleTrack) + assertThat(snapshot).isNull() + } + + @Test + fun computeDefaultSelection_centersOnActiveIndex() { + // 10 lines, active is index 5 -> window of 3 centered around 5: [4..6] + val range = computeDefaultSelection(totalLines = 10, activeIndex = 5) + assertThat(range).isEqualTo(4..6) + + // Near start: active is 0 -> [0..2] + val rangeStart = computeDefaultSelection(totalLines = 10, activeIndex = 0) + assertThat(rangeStart).isEqualTo(0..2) + + // Near end: active is 9 -> [7..9] + val rangeEnd = computeDefaultSelection(totalLines = 10, activeIndex = 9) + assertThat(rangeEnd).isEqualTo(7..9) + + // Invalid activeIndex -> starts at top [0..2] + val rangeInvalid = computeDefaultSelection(totalLines = 10, activeIndex = -1) + assertThat(rangeInvalid).isEqualTo(0..2) + + // Less than 3 lines total: selects all available lines + val rangeSmall = computeDefaultSelection(totalLines = 2, activeIndex = 1) + assertThat(rangeSmall).isEqualTo(0..1) + } + + @Test + fun updateContiguousSelection_extendsAdjacentLines() { + var capCalled = false + val initial = 2..3 + + // Extend after + val extendedAfter = updateContiguousSelection(initial, tappedIndex = 4, totalLines = 10) { capCalled = true } + assertThat(extendedAfter).isEqualTo(2..4) + assertThat(capCalled).isFalse() + + // Extend before + val extendedBefore = updateContiguousSelection(initial, tappedIndex = 1, totalLines = 10) { capCalled = true } + assertThat(extendedBefore).isEqualTo(1..3) + assertThat(capCalled).isFalse() + } + + @Test + fun updateContiguousSelection_shrinksEdges() { + val initial = 2..4 + + // Shrink start + val shrinkStart = updateContiguousSelection(initial, tappedIndex = 2, totalLines = 10) + assertThat(shrinkStart).isEqualTo(3..4) + + // Shrink end + val shrinkEnd = updateContiguousSelection(initial, tappedIndex = 4, totalLines = 10) + assertThat(shrinkEnd).isEqualTo(2..3) + } + + @Test + fun updateContiguousSelection_singleLineDoesNotShrinkToZero() { + val initial = 3..3 + val result = updateContiguousSelection(initial, tappedIndex = 3, totalLines = 10) + assertThat(result).isEqualTo(3..3) + } + + @Test + fun updateContiguousSelection_tappingInnerLineRestartsSelection() { + val initial = 1..5 + val result = updateContiguousSelection(initial, tappedIndex = 3, totalLines = 10) + assertThat(result).isEqualTo(3..3) + } + + @Test + fun updateContiguousSelection_tappingNonAdjacentLineRestartsSelection() { + val initial = 2..3 + val result = updateContiguousSelection(initial, tappedIndex = 7, totalLines = 10) + assertThat(result).isEqualTo(7..7) + } + + @Test + fun updateContiguousSelection_capsAtMaxFiveLines() { + var capCalled = false + val fiveLines = 1..5 + + val result = updateContiguousSelection( + currentRange = fiveLines, + tappedIndex = 6, + totalLines = 10, + maxLines = 5, + onCapReached = { capCalled = true }, + ) + + assertThat(result).isEqualTo(1..5) + assertThat(capCalled).isTrue() + } +} diff --git a/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/ShareCardMathTest.kt b/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/ShareCardMathTest.kt new file mode 100644 index 0000000000..44f3668ce7 --- /dev/null +++ b/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/ShareCardMathTest.kt @@ -0,0 +1,73 @@ +package com.lastwave.app.ui.player.lyricsshare + +import com.google.common.truth.Truth.assertThat +import kotlin.math.roundToInt +import org.junit.Test + +class ShareCardMathTest { + + @Test + fun cardDimensions_target1080_clampsBetween1to1And9to16() { + val targetWidth = 1080 + val scale = 1.0f + + val width = (targetWidth * scale).roundToInt().coerceAtLeast(320) + val minH = (width * 1.0f).roundToInt() + val maxH = (width * 16.0f / 9.0f).roundToInt() + + assertThat(width).isEqualTo(1080) + // 1:1 ratio -> 1080 x 1080 (min square clamp) + assertThat(minH).isEqualTo(1080) + // 9:16 ratio -> 1080 x 1920 (max story clamp) + assertThat(maxH).isEqualTo(1920) + + // Short lyrics should clamp to 1080 (1:1) + val measuredShort = 900 + val clampedShort = measuredShort.coerceIn(minH, maxH) + assertThat(clampedShort).isEqualTo(1080) + + // Long lyrics should clamp to 1920 (9:16) + val measuredLong = 2400 + val clampedLong = measuredLong.coerceIn(minH, maxH) + assertThat(clampedLong).isEqualTo(1920) + + // Intermediate lyrics stays within bounds + val measuredMid = 1450 + val clampedMid = measuredMid.coerceIn(minH, maxH) + assertThat(clampedMid).isEqualTo(1450) + } + + @Test + fun cardDimensions_scaled075_preservesAspectBounds() { + val targetWidth = 1080 + val scale = 0.75f + + val width = (targetWidth * scale).roundToInt().coerceAtLeast(320) + val minH = (width * 1.0f).roundToInt() + val maxH = (width * 16.0f / 9.0f).roundToInt() + + assertThat(width).isEqualTo(810) + assertThat(minH).isEqualTo(810) + assertThat(maxH).isEqualTo(1440) + + val ratioMin = width.toDouble() / minH + assertThat(ratioMin).isWithin(0.01).of(1.0) + + val ratioMax = width.toDouble() / maxH + assertThat(ratioMax).isWithin(0.01).of(9.0 / 16.0) + } + + @Test + fun truncateEllipsis_keepsTextUnderLimit() { + val longTitle = "A".repeat(100) + val maxChars = 40 + val truncated = if (longTitle.length > maxChars) { + longTitle.take(maxChars - 1) + "…" + } else { + longTitle + } + + assertThat(truncated).hasLength(40) + assertThat(truncated).endsWith("…") + } +} diff --git a/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/ShareCardRendererTest.kt b/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/ShareCardRendererTest.kt new file mode 100644 index 0000000000..d0ef019bec --- /dev/null +++ b/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/ShareCardRendererTest.kt @@ -0,0 +1,309 @@ +package com.lastwave.app.ui.player.lyricsshare + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Paint +import android.text.TextPaint +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ShareCardRendererTest { + + private lateinit var context: Context + private lateinit var sampleCover: Bitmap + private lateinit var sampleBlur: Bitmap + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + sampleCover = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888).apply { + eraseColor(0xFF2563EB.toInt()) + } + sampleBlur = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888).apply { + eraseColor(0xFF1E293B.toInt()) + } + } + + @Test + fun renderSongCard_withCoverArt_rendersSuccessfully() = runTest { + val spec = ShareCardSpec( + mode = CardMode.Song, + lines = listOf( + ShareLine("I feel it coming", isRtl = false), + ShareLine("I feel it coming, babe", isRtl = false), + ), + title = "I Feel It Coming", + artist = "The Weeknd ft. Daft Punk", + coverBitmap = sampleCover, + swatchColor = ColorContrastHelper.BRAND_LIME, + targetWidth = 720, + ) + + val bitmap = ShareCardRenderer.renderCard(context, spec) + + assertThat(bitmap).isNotNull() + assertThat(bitmap.width).isEqualTo(720) + assertThat(bitmap.height).isAtLeast(720) // 1:1 minimum + assertThat(bitmap.height).isAtMost(720 * 16 / 9) // 9:16 maximum + } + + @Test + fun renderSongCard_noArt_rendersWithFallback() = runTest { + val spec = ShareCardSpec( + mode = CardMode.Song, + lines = listOf( + ShareLine("No artwork line 1", isRtl = false), + ShareLine("No artwork line 2", isRtl = false), + ), + title = "Acapella Song", + artist = "Vocalist", + coverBitmap = null, + swatchColor = 0xFF12161A.toInt(), + targetWidth = 720, + ) + + val bitmap = ShareCardRenderer.renderCard(context, spec) + + assertThat(bitmap).isNotNull() + assertThat(bitmap.width).isEqualTo(720) + assertThat(bitmap.height).isAtLeast(720) + } + + @Test + fun renderSongCard_failedArt_usesBrandFallbackGradient() = runTest { + val fallbackGradient = ArtworkResolver.createBrandFallbackGradient(ColorContrastHelper.BRAND_LIME, 200, 200) + val spec = ShareCardSpec( + mode = CardMode.Song, + lines = listOf( + ShareLine("Failed art fallback line", isRtl = false), + ), + title = "Offline Song", + artist = "Unknown Artist", + coverBitmap = fallbackGradient, + swatchColor = ColorContrastHelper.BRAND_LIME, + targetWidth = 720, + ) + + val bitmap = ShareCardRenderer.renderCard(context, spec) + + assertThat(bitmap).isNotNull() + assertThat(bitmap.width).isEqualTo(720) + assertThat(bitmap.height).isAtLeast(720) + } + + @Test + fun renderLyricsCard_withHeaderThumbnail_rendersSuccessfully() = runTest { + val spec = ShareCardSpec( + mode = CardMode.Lyrics, + lines = listOf( + ShareLine("I can't see clearly when you're gone", isRtl = false), + ShareLine("I said, \"Ooh, I'm blinded by the lights\"", isRtl = false), + ShareLine("No, I can't sleep until I feel your touch\"", isRtl = false), + ), + title = "Blinding Lights", + artist = "The Weeknd", + coverBitmap = sampleCover, + swatchColor = 0xFF78350F.toInt(), // Warm Amber / Brown like YT Music + targetWidth = 720, + ) + + val bitmap = ShareCardRenderer.renderCard(context, spec) + + assertThat(bitmap).isNotNull() + assertThat(bitmap.width).isEqualTo(720) + assertThat(bitmap.height).isAtLeast(720) + assertThat(bitmap.height).isAtMost(720 * 16 / 9) + } + + @Test + fun renderLyricsCard_noArt_rendersWithFallback() = runTest { + val spec = ShareCardSpec( + mode = CardMode.Lyrics, + lines = listOf( + ShareLine("Solo line without art", isRtl = false), + ), + title = "Acoustic Solo", + artist = "Guitarist", + coverBitmap = null, + swatchColor = 0xFF0F172A.toInt(), + targetWidth = 720, + ) + + val bitmap = ShareCardRenderer.renderCard(context, spec) + + assertThat(bitmap).isNotNull() + assertThat(bitmap.width).isEqualTo(720) + assertThat(bitmap.height).isAtLeast(720) + } + + @Test + fun renderLyricsCard_coverBackground_rendersSuccessfullyWithDimmedScrim() = runTest { + val spec = ShareCardSpec( + mode = CardMode.Lyrics, + lines = listOf( + ShareLine("No one's around to judge me (oh)", isRtl = false), + ShareLine("I can't see clearly when you're gone", isRtl = false), + ShareLine("I said, \"Ooh, I'm blinded by the lights\"", isRtl = false), + ), + title = "Blinding Lights", + artist = "The Weeknd", + coverBitmap = sampleCover, + swatchColor = ColorContrastHelper.SWATCH_COVER_ART, + derivedColors = ColorContrastHelper.deriveTextColors(ColorContrastHelper.SWATCH_COVER_ART, isCoverScrim = true), + isCoverBackground = true, + targetWidth = 720, + ) + + val bitmap = ShareCardRenderer.renderCard(context, spec) + + assertThat(bitmap).isNotNull() + assertThat(bitmap.width).isEqualTo(720) + assertThat(bitmap.height).isAtLeast(720) + assertThat(bitmap.height).isAtMost(720 * 16 / 9) + } + + @Test + fun lyricWrapping_longLineProducesMultipleRows_shortLineProducesSingleRow() { + val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = 84f + } + val contentWidth = 60 + + val shortText = "I feel" + val shortLayout = ShareCardRenderer.createStaticLayout(shortText, paint, contentWidth, isRtl = false) + val longText = "This is an exceptionally long lyric line that definitely wraps onto multiple distinct rows within the card boundaries" + val longLayout = ShareCardRenderer.createStaticLayout(longText, paint, contentWidth, isRtl = false) + assertThat(shortLayout.lineCount).isEqualTo(1) + assertThat(longLayout.lineCount).isGreaterThan(1) + } + + @Test + fun lyricSpacing_gapBetweenDifferentLyricsIsGreaterThanWrappedRowSpacing() { + val fontSize = 84f + val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = fontSize + } + val contentWidth = 40 + + val multiRowLyric = "I said, \"Ooh, I'm blinded by the lights, no I can't sleep until I feel your touch\"" + val layout = ShareCardRenderer.createStaticLayout(multiRowLyric, paint, contentWidth, isRtl = false) + assertThat(layout.lineCount).isGreaterThan(1) + + val row0Bottom = layout.getLineBottom(0) + val row1Top = layout.getLineTop(1) + val wrappedRowGap = (row1Top - row0Bottom).coerceAtLeast(0) + + val blockGap = fontSize * ShareCardRenderer.LYRIC_BLOCK_GAP_FACTOR + assertThat(blockGap).isGreaterThan(wrappedRowGap.toFloat()) + } + + @Test + fun heightClamp_shortCardClampsToSquare1to1_longCardClampsTo9to16() = runTest { + val shortSpec = ShareCardSpec( + mode = CardMode.Lyrics, + lines = listOf(ShareLine("Single line", isRtl = false)), + title = "Title", + artist = "Artist", + targetWidth = 720, + ) + val shortBitmap = ShareCardRenderer.renderCard(context, shortSpec) + assertThat(shortBitmap.width).isEqualTo(720) + assertThat(shortBitmap.height).isEqualTo(720) // 1:1 minimum + + val veryLongText = "This is an exceptionally long lyric line designed to wrap onto multiple rows to test vertical clamping and font scaling under tight constraints" + val longSpec = ShareCardSpec( + mode = CardMode.Lyrics, + lines = (1..10).map { + ShareLine("$veryLongText (stanza $it)", isRtl = false) + }, + title = "Long Song Title That Wraps Across Rows", + artist = "Long Artist Name", + targetWidth = 720, + ) + val longBitmap = ShareCardRenderer.renderCard(context, longSpec) + assertThat(longBitmap.width).isEqualTo(720) + assertThat(longBitmap.height).isAtMost(720 * 16 / 9) // 9:16 maximum bound + assertThat(longBitmap.height).isAtLeast(1200) + } + + @Test + fun renderText_latinScript_rendersSuccessfully() = runTest { + val spec = ShareCardSpec( + mode = CardMode.Lyrics, + lines = listOf( + ShareLine("Never gonna give you up, never gonna let you down", isRtl = false), + ShareLine("Never gonna run around and desert you", isRtl = false), + ), + title = "Never Gonna Give You Up", + artist = "Rick Astley", + targetWidth = 720, + ) + + val bitmap = ShareCardRenderer.renderCard(context, spec) + assertThat(bitmap).isNotNull() + assertThat(bitmap.width).isEqualTo(720) + } + + @Test + fun renderText_devanagariScript_rendersSuccessfullyWithMatras() = runTest { + val spec = ShareCardSpec( + mode = CardMode.Lyrics, + lines = listOf( + ShareLine("तू ही मेरी मंज़िल, पहचान भी तू", isRtl = false), + ShareLine("तेरे बिना ज़िंदगी से कोई शिकवा तो नहीं", isRtl = false), + ), + title = "तेरे बिना", + artist = "लता मंगेशकर", + targetWidth = 720, + ) + + val bitmap = ShareCardRenderer.renderCard(context, spec) + assertThat(bitmap).isNotNull() + assertThat(bitmap.width).isEqualTo(720) + } + + @Test + fun renderText_arabicScript_rendersWithRtlAlignment() = runTest { + val spec = ShareCardSpec( + mode = CardMode.Lyrics, + lines = listOf( + ShareLine("أراك عصي الدمع شيمتك الصبر", isRtl = true), + ShareLine("أما للهوى نهي عليك ولا أمر", isRtl = true), + ), + title = "أراك عصي الدمع", + artist = "أم كلثوم", + isRtl = true, + targetWidth = 720, + ) + + val bitmap = ShareCardRenderer.renderCard(context, spec) + assertThat(bitmap).isNotNull() + assertThat(bitmap.width).isEqualTo(720) + } + + @Test + fun renderText_cjkScript_rendersSuccessfully() = runTest { + val spec = ShareCardSpec( + mode = CardMode.Lyrics, + lines = listOf( + ShareLine("夜に駆ける 沈むように溶けてゆくように", isRtl = false), + ShareLine("広がる空の果てに何があるのだろう", isRtl = false), + ), + title = "夜に駆ける", + artist = "YOASOBI", + targetWidth = 720, + ) + + val bitmap = ShareCardRenderer.renderCard(context, spec) + assertThat(bitmap).isNotNull() + assertThat(bitmap.width).isEqualTo(720) + } +} diff --git a/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/ShareIntentTest.kt b/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/ShareIntentTest.kt new file mode 100644 index 0000000000..37a5c84258 --- /dev/null +++ b/app/src/test/java/com/lastwave/app/ui/player/lyricsshare/ShareIntentTest.kt @@ -0,0 +1,88 @@ +package com.lastwave.app.ui.player.lyricsshare + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import androidx.core.content.FileProvider +import com.google.common.truth.Truth.assertThat +import com.lastwave.app.util.FileExportHelper +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class ShareIntentTest { + + private lateinit var context: Context + private lateinit var fileExportHelper: FileExportHelper + private lateinit var sampleBitmap: Bitmap + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + fileExportHelper = FileExportHelper(context) + sampleBitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888).apply { + eraseColor(0xFFC9FB00.toInt()) + } + } + + @After + fun tearDown() { + unmockkStatic(FileProvider::class) + } + + @Test + fun shareImage_constructsValidSendIntentWithFileProviderStreamAndFlags() = runTest { + mockkStatic(FileProvider::class) + val expectedUri = Uri.parse("content://${context.packageName}.provider/shared/lyrics_cards/test.png") + every { FileProvider.getUriForFile(any(), any(), any()) } returns expectedUri + + val result = fileExportHelper.shareImage( + activityContext = context, + bitmap = sampleBitmap, + title = "Test Song", + ) + + assertThat(result.isSuccess).isTrue() + + val nextIntent = shadowOf(context as android.app.Application).nextStartedActivity + assertThat(nextIntent).isNotNull() + assertThat(nextIntent.action).isEqualTo(Intent.ACTION_CHOOSER) + + val targetIntent = nextIntent.getParcelableExtra(Intent.EXTRA_INTENT) + assertThat(targetIntent).isNotNull() + assertThat(targetIntent!!.action).isEqualTo(Intent.ACTION_SEND) + assertThat(targetIntent.type).isEqualTo("image/png") + + val streamUri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM) + assertThat(streamUri).isEqualTo(expectedUri) + assertThat(streamUri!!.scheme).isEqualTo("content") + assertThat(streamUri.authority).isEqualTo("${context.packageName}.provider") + + // Read URI permission flag must be present + val flags = targetIntent.flags + assertThat(flags and Intent.FLAG_GRANT_READ_URI_PERMISSION).isNotEqualTo(0) + } + + @Test + fun saveImageToGallery_insertsIntoMediaStoreAndReturnsContentUri() = runTest { + val result = fileExportHelper.saveImageToGallery( + bitmap = sampleBitmap, + title = "Saved Lyrics Card", + ) + + assertThat(result.isSuccess).isTrue() + val uri = result.getOrNull() + assertThat(uri).isNotNull() + assertThat(uri!!.scheme).isEqualTo("content") + } +}