diff --git a/app/src/main/java/xyz/cottongin/radio247/data/db/StationDao.kt b/app/src/main/java/xyz/cottongin/radio247/data/db/StationDao.kt index a33224b..3bb7f2c 100644 --- a/app/src/main/java/xyz/cottongin/radio247/data/db/StationDao.kt +++ b/app/src/main/java/xyz/cottongin/radio247/data/db/StationDao.kt @@ -54,4 +54,7 @@ interface StationDao { @Query("UPDATE stations SET listenerCount = :count WHERE id = :id") suspend fun updateListenerCount(id: Long, count: Int) + + @Query("UPDATE stations SET defaultArtworkUrl = :artworkUrl WHERE id = :id") + suspend fun updateArtworkUrl(id: Long, artworkUrl: String?) } diff --git a/app/src/main/java/xyz/cottongin/radio247/metadata/AlbumArtResolver.kt b/app/src/main/java/xyz/cottongin/radio247/metadata/AlbumArtResolver.kt index 96972f5..457dd03 100644 --- a/app/src/main/java/xyz/cottongin/radio247/metadata/AlbumArtResolver.kt +++ b/app/src/main/java/xyz/cottongin/radio247/metadata/AlbumArtResolver.kt @@ -11,7 +11,7 @@ import java.net.URLEncoder class AlbumArtResolver( private val client: OkHttpClient, - private val artCache: ArtCache = ArtCache(), + val artCache: ArtCache = ArtCache(), private val musicBrainzBaseUrl: String = "https://musicbrainz.org", private val coverArtBaseUrl: String = "https://coverartarchive.org", private val skipArtVerification: Boolean = false, diff --git a/app/src/main/java/xyz/cottongin/radio247/metadata/ArtCache.kt b/app/src/main/java/xyz/cottongin/radio247/metadata/ArtCache.kt index fd7f09a..2831362 100644 --- a/app/src/main/java/xyz/cottongin/radio247/metadata/ArtCache.kt +++ b/app/src/main/java/xyz/cottongin/radio247/metadata/ArtCache.kt @@ -14,4 +14,14 @@ class ArtCache(private val maxSize: Int = 500) { fun put(key: String, url: String) { cache[key] = url } + + @Synchronized + fun removeByValue(url: String) { + cache.entries.removeAll { it.value == url } + } + + @Synchronized + fun clear() { + cache.clear() + } } diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/AddStationDialog.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/AddStationDialog.kt index cd9826d..a45e768 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/AddStationDialog.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/AddStationDialog.kt @@ -1,10 +1,23 @@ package xyz.cottongin.radio247.ui.screens.stationlist +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Link import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -13,23 +26,98 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.res.painterResource import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import xyz.cottongin.radio247.R @Composable fun AddStationDialog( onDismiss: () -> Unit, - onConfirm: (name: String, url: String) -> Unit, + onConfirm: (name: String, url: String, artworkUrl: String?) -> Unit, + onPickImage: (onResult: (String) -> Unit) -> Unit, modifier: Modifier = Modifier ) { var name by remember { mutableStateOf("") } var url by remember { mutableStateOf("") } + var artworkUrl by remember { mutableStateOf(null) } + var showUrlField by remember { mutableStateOf(false) } AlertDialog( onDismissRequest = onDismiss, title = { Text("Add Station") }, text = { - Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant, CircleShape), + contentAlignment = Alignment.Center + ) { + if (artworkUrl != null) { + AsyncImage( + model = artworkUrl, + contentDescription = "Station artwork", + modifier = Modifier.size(80.dp).clip(CircleShape) + ) + } else { + Icon( + painter = painterResource(R.drawable.ic_radio_placeholder), + contentDescription = "No artwork", + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { onPickImage { picked -> artworkUrl = picked } }, + modifier = Modifier.size(36.dp) + ) { + Icon(Icons.Default.Image, contentDescription = "Pick image", modifier = Modifier.size(20.dp)) + } + IconButton( + onClick = { showUrlField = !showUrlField }, + modifier = Modifier.size(36.dp) + ) { + Icon(Icons.Default.Link, contentDescription = "Set URL", modifier = Modifier.size(20.dp)) + } + if (artworkUrl != null) { + IconButton( + onClick = { artworkUrl = null }, + modifier = Modifier.size(36.dp) + ) { + Icon(Icons.Default.Close, contentDescription = "Remove artwork", modifier = Modifier.size(20.dp)) + } + } + } + + if (showUrlField) { + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = artworkUrl ?: "", + onValueChange = { artworkUrl = it.ifBlank { null } }, + label = { Text("Artwork URL") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( value = name, onValueChange = { name = it }, @@ -51,7 +139,7 @@ fun AddStationDialog( TextButton( onClick = { if (name.isNotBlank() && url.isNotBlank()) { - onConfirm(name.trim(), url.trim()) + onConfirm(name.trim(), url.trim(), artworkUrl?.trim()) onDismiss() } } diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/EditStationDialog.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/EditStationDialog.kt index 3327996..ed68791 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/EditStationDialog.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/EditStationDialog.kt @@ -1,10 +1,25 @@ package xyz.cottongin.radio247.ui.screens.stationlist +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoFixHigh +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -13,25 +28,109 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.res.painterResource import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import xyz.cottongin.radio247.R import xyz.cottongin.radio247.data.model.Station @Composable fun EditStationDialog( station: Station, onDismiss: () -> Unit, - onConfirm: (name: String, url: String) -> Unit, + onConfirm: (name: String, url: String, artworkUrl: String?) -> Unit, + onPickImage: () -> Unit, + onGenerateArt: () -> Unit, + onClearCache: () -> Unit, + onDeleteArt: () -> Unit, modifier: Modifier = Modifier ) { var name by remember(station.id) { mutableStateOf(station.name) } var url by remember(station.id) { mutableStateOf(station.url) } + var artworkUrl by remember(station.id) { mutableStateOf(station.defaultArtworkUrl) } + var showUrlField by remember { mutableStateOf(false) } AlertDialog( onDismissRequest = onDismiss, title = { Text("Edit Station") }, text = { - Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant, CircleShape), + contentAlignment = Alignment.Center + ) { + if (artworkUrl != null) { + AsyncImage( + model = artworkUrl, + contentDescription = "Station artwork", + modifier = Modifier.size(80.dp).clip(CircleShape) + ) + } else { + Icon( + painter = painterResource(R.drawable.ic_radio_placeholder), + contentDescription = "No artwork", + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onPickImage, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Image, contentDescription = "Pick image", modifier = Modifier.size(20.dp)) + } + IconButton( + onClick = { showUrlField = !showUrlField }, + modifier = Modifier.size(36.dp) + ) { + Icon(Icons.Default.Link, contentDescription = "Set URL", modifier = Modifier.size(20.dp)) + } + IconButton(onClick = onGenerateArt, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.AutoFixHigh, contentDescription = "Generate art", modifier = Modifier.size(20.dp)) + } + if (artworkUrl != null) { + IconButton(onClick = onClearCache, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Refresh, contentDescription = "Clear cache", modifier = Modifier.size(20.dp)) + } + IconButton( + onClick = { + artworkUrl = null + onDeleteArt() + }, + modifier = Modifier.size(36.dp) + ) { + Icon(Icons.Default.Close, contentDescription = "Remove artwork", modifier = Modifier.size(20.dp)) + } + } + } + + if (showUrlField) { + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = artworkUrl ?: "", + onValueChange = { artworkUrl = it.ifBlank { null } }, + label = { Text("Artwork URL") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( value = name, onValueChange = { name = it }, @@ -53,7 +152,7 @@ fun EditStationDialog( TextButton( onClick = { if (name.isNotBlank() && url.isNotBlank()) { - onConfirm(name.trim(), url.trim()) + onConfirm(name.trim(), url.trim(), artworkUrl?.trim()) onDismiss() } } diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt index 46ccbb3..33e21a9 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt @@ -107,6 +107,8 @@ fun StationListScreen( var qualityStreams by remember { mutableStateOf>(emptyList()) } var qualityCurrentOrder by remember { mutableStateOf>(emptyList()) } var tabToRename by remember { mutableStateOf(null) } + var artworkPickTarget by remember { mutableStateOf(null) } + var addDialogArtCallback by remember { mutableStateOf<((String) -> Unit)?>(null) } val importLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument(), @@ -115,6 +117,29 @@ fun StationListScreen( } ) + val artworkPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + onResult = { uri: Uri? -> + if (uri != null) { + val target = artworkPickTarget + val addCallback = addDialogArtCallback + when { + target != null -> { + viewModel.copyImageToInternal(target.id, uri) + artworkPickTarget = null + } + addCallback != null -> { + addCallback(uri.toString()) + addDialogArtCallback = null + } + } + } else { + artworkPickTarget = null + addDialogArtCallback = null + } + } + ) + val currentTab = viewState.tabs.getOrNull(viewState.selectedTabIndex) val isBuiltInTab = currentTab?.isBuiltIn == true @@ -249,6 +274,13 @@ fun StationListScreen( onEdit = { stationToEdit = station }, onDelete = { viewModel.deleteStation(station) }, onToggleHidden = { viewModel.toggleHidden(station) }, + onSetArtwork = { + artworkPickTarget = station + artworkPickerLauncher.launch("image/*") + }, + onClearArtCache = { viewModel.clearStationArtworkCache(station) }, + onRemoveArtwork = { viewModel.deleteStationArtwork(station.id) }, + onGenerateArt = { viewModel.generateStationArtwork(station) }, onQuality = { viewModel.getStreamsForStation(station.id) { streams -> viewModel.getQualityOverrideForStation(station.id) { json -> @@ -282,9 +314,13 @@ fun StationListScreen( if (showAddStation) { AddStationDialog( onDismiss = { showAddStation = false }, - onConfirm = { name, url -> - viewModel.addStation(name, url) + onConfirm = { name, url, artworkUrl -> + viewModel.addStation(name, url, artworkUrl) showAddStation = false + }, + onPickImage = { callback -> + addDialogArtCallback = callback + artworkPickerLauncher.launch("image/*") } ) } @@ -303,10 +339,17 @@ fun StationListScreen( EditStationDialog( station = station, onDismiss = { stationToEdit = null }, - onConfirm = { name, url -> - viewModel.updateStation(station, name, url) + onConfirm = { name, url, artworkUrl -> + viewModel.updateStation(station, name, url, artworkUrl) stationToEdit = null - } + }, + onPickImage = { + artworkPickTarget = station + artworkPickerLauncher.launch("image/*") + }, + onGenerateArt = { viewModel.generateStationArtwork(station) }, + onClearCache = { viewModel.clearStationArtworkCache(station) }, + onDeleteArt = { viewModel.deleteStationArtwork(station.id) } ) } @@ -597,6 +640,10 @@ private fun StationRow( onDelete: () -> Unit, onToggleHidden: () -> Unit, onQuality: () -> Unit, + onSetArtwork: () -> Unit, + onClearArtCache: () -> Unit, + onRemoveArtwork: () -> Unit, + onGenerateArt: () -> Unit, modifier: Modifier = Modifier ) { var showMenu by remember { mutableStateOf(false) } @@ -716,6 +763,38 @@ private fun StationRow( } ) } + if (!isHiddenView) { + DropdownMenuItem( + text = { Text("Set Artwork") }, + onClick = { + showMenu = false + onSetArtwork() + } + ) + DropdownMenuItem( + text = { Text("Generate Artwork") }, + onClick = { + showMenu = false + onGenerateArt() + } + ) + if (station.defaultArtworkUrl != null) { + DropdownMenuItem( + text = { Text("Clear Art Cache") }, + onClick = { + showMenu = false + onClearArtCache() + } + ) + DropdownMenuItem( + text = { Text("Remove Artwork") }, + onClick = { + showMenu = false + onRemoveArtwork() + } + ) + } + } } } } diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt index d62c919..c4b4867 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt @@ -1,6 +1,8 @@ package xyz.cottongin.radio247.ui.screens.stationlist import android.app.Application +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope @@ -13,6 +15,7 @@ import xyz.cottongin.radio247.data.model.Playlist import xyz.cottongin.radio247.data.model.Station import xyz.cottongin.radio247.data.model.StationPreference import xyz.cottongin.radio247.data.model.StationStream +import xyz.cottongin.radio247.util.ArtworkGenerator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -26,6 +29,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.File data class TabInfo( val playlist: Playlist?, @@ -250,17 +254,55 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat viewModelScope.launch { stationDao.delete(station) } } - fun addStation(name: String, url: String) { + fun addStation(name: String, url: String, artworkUrl: String? = null) { val currentTab = viewState.value.tabs.getOrNull(viewState.value.selectedTabIndex) val playlistId = currentTab?.playlist?.id viewModelScope.launch { - stationDao.insert(Station(name = name, url = url, playlistId = playlistId)) + stationDao.insert(Station(name = name, url = url, playlistId = playlistId, defaultArtworkUrl = artworkUrl)) } } - fun updateStation(station: Station, name: String, url: String) { + fun updateStation(station: Station, name: String, url: String, artworkUrl: String? = station.defaultArtworkUrl) { viewModelScope.launch { - stationDao.update(station.copy(name = name, url = url)) + stationDao.update(station.copy(name = name, url = url, defaultArtworkUrl = artworkUrl)) + } + } + + fun updateStationArtwork(stationId: Long, artworkUrl: String?) { + viewModelScope.launch { + stationDao.updateArtworkUrl(stationId, artworkUrl) + } + } + + fun deleteStationArtwork(stationId: Long) { + updateStationArtwork(stationId, null) + } + + fun clearStationArtworkCache(station: Station) { + val url = station.defaultArtworkUrl ?: return + app.albumArtResolver.artCache.removeByValue(url) + } + + fun generateStationArtwork(station: Station) { + viewModelScope.launch(Dispatchers.IO) { + val path = ArtworkGenerator.generateAndSave(app, station.id, station.name) + stationDao.updateArtworkUrl(station.id, path) + } + } + + fun copyImageToInternal(stationId: Long, sourceUri: Uri) { + viewModelScope.launch(Dispatchers.IO) { + val dir = File(app.filesDir, "station_art") + dir.mkdirs() + val destFile = File(dir, "$stationId.png") + app.contentResolver.openInputStream(sourceUri)?.use { input -> + val bitmap = BitmapFactory.decodeStream(input) ?: return@launch + destFile.outputStream().use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + bitmap.recycle() + } ?: return@launch + stationDao.updateArtworkUrl(stationId, destFile.toURI().toString()) } } diff --git a/app/src/main/java/xyz/cottongin/radio247/util/ArtworkGenerator.kt b/app/src/main/java/xyz/cottongin/radio247/util/ArtworkGenerator.kt new file mode 100644 index 0000000..f06688b --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/util/ArtworkGenerator.kt @@ -0,0 +1,77 @@ +package xyz.cottongin.radio247.util + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import java.io.File +import kotlin.math.abs + +object ArtworkGenerator { + + private val PALETTE = intArrayOf( + 0xFF1E88E5.toInt(), // Blue + 0xFF43A047.toInt(), // Green + 0xFFE53935.toInt(), // Red + 0xFF8E24AA.toInt(), // Purple + 0xFFFB8C00.toInt(), // Orange + 0xFF00ACC1.toInt(), // Cyan + 0xFF3949AB.toInt(), // Indigo + 0xFF7CB342.toInt(), // Light Green + 0xFFD81B60.toInt(), // Pink + 0xFF5E35B1.toInt(), // Deep Purple + 0xFF039BE5.toInt(), // Light Blue + 0xFFC0CA33.toInt(), // Lime + ) + + fun extractInitials(name: String): String { + val words = name.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() } + return when { + words.size >= 2 -> "${words[0].first().uppercaseChar()}${words[1].first().uppercaseChar()}" + words.size == 1 && words[0].length >= 2 -> words[0].take(2).uppercase() + words.size == 1 -> words[0].first().uppercaseChar().toString() + else -> "?" + } + } + + fun pickColor(name: String): Int { + return PALETTE[abs(name.hashCode()) % PALETTE.size] + } + + fun generateBitmap(name: String, sizePx: Int = 256): Bitmap { + val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + + val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = pickColor(name) + style = Paint.Style.FILL + } + val cx = sizePx / 2f + val cy = sizePx / 2f + canvas.drawCircle(cx, cy, cx, bgPaint) + + val initials = extractInitials(name) + val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textSize = sizePx * 0.38f + typeface = Typeface.DEFAULT_BOLD + textAlign = Paint.Align.CENTER + } + val textY = cy - (textPaint.descent() + textPaint.ascent()) / 2f + canvas.drawText(initials, cx, textY, textPaint) + + return bitmap + } + + fun generateAndSave(context: Context, stationId: Long, stationName: String): String { + val dir = File(context.filesDir, "station_art") + dir.mkdirs() + val file = File(dir, "$stationId.png") + val bitmap = generateBitmap(stationName) + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + bitmap.recycle() + return file.toURI().toString() + } +} diff --git a/app/src/test/java/xyz/cottongin/radio247/metadata/ArtCacheTest.kt b/app/src/test/java/xyz/cottongin/radio247/metadata/ArtCacheTest.kt new file mode 100644 index 0000000..6b22619 --- /dev/null +++ b/app/src/test/java/xyz/cottongin/radio247/metadata/ArtCacheTest.kt @@ -0,0 +1,75 @@ +package xyz.cottongin.radio247.metadata + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ArtCacheTest { + + @Test + fun get_returnsNullForMissingKey() { + val cache = ArtCache() + assertNull(cache.get("nonexistent")) + } + + @Test + fun put_and_get() { + val cache = ArtCache() + cache.put("key1", "https://example.com/art.jpg") + assertEquals("https://example.com/art.jpg", cache.get("key1")) + } + + @Test + fun evicts_when_exceeding_maxSize() { + val cache = ArtCache(maxSize = 2) + cache.put("a", "url-a") + cache.put("b", "url-b") + cache.put("c", "url-c") + assertNull(cache.get("a")) + assertEquals("url-b", cache.get("b")) + assertEquals("url-c", cache.get("c")) + } + + @Test + fun removeByValue_removesMatchingEntries() { + val cache = ArtCache() + cache.put("key1", "https://station.com/art.png") + cache.put("key2", "https://other.com/art.png") + cache.put("key3", "https://station.com/art.png") + + cache.removeByValue("https://station.com/art.png") + + assertNull(cache.get("key1")) + assertEquals("https://other.com/art.png", cache.get("key2")) + assertNull(cache.get("key3")) + } + + @Test + fun removeByValue_noOpWhenValueNotPresent() { + val cache = ArtCache() + cache.put("key1", "url-a") + cache.removeByValue("url-b") + assertEquals("url-a", cache.get("key1")) + } + + @Test + fun clear_removesAllEntries() { + val cache = ArtCache() + cache.put("a", "url-a") + cache.put("b", "url-b") + cache.put("c", "url-c") + + cache.clear() + + assertNull(cache.get("a")) + assertNull(cache.get("b")) + assertNull(cache.get("c")) + } + + @Test + fun clear_onEmptyCacheIsNoOp() { + val cache = ArtCache() + cache.clear() + assertNull(cache.get("anything")) + } +} diff --git a/app/src/test/java/xyz/cottongin/radio247/util/ArtworkGeneratorTest.kt b/app/src/test/java/xyz/cottongin/radio247/util/ArtworkGeneratorTest.kt new file mode 100644 index 0000000..63ba38f --- /dev/null +++ b/app/src/test/java/xyz/cottongin/radio247/util/ArtworkGeneratorTest.kt @@ -0,0 +1,88 @@ +package xyz.cottongin.radio247.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ArtworkGeneratorTest { + + @Test + fun extractInitials_twoWords() { + assertEquals("GS", ArtworkGenerator.extractInitials("Groove Salad")) + } + + @Test + fun extractInitials_threeWords() { + assertEquals("DR", ArtworkGenerator.extractInitials("Drone Radio Station")) + } + + @Test + fun extractInitials_singleLongWord() { + assertEquals("SO", ArtworkGenerator.extractInitials("SomaFM")) + } + + @Test + fun extractInitials_singleChar() { + assertEquals("X", ArtworkGenerator.extractInitials("X")) + } + + @Test + fun extractInitials_blank() { + assertEquals("?", ArtworkGenerator.extractInitials("")) + } + + @Test + fun extractInitials_whitespace() { + assertEquals("?", ArtworkGenerator.extractInitials(" ")) + } + + @Test + fun extractInitials_leadingTrailingSpaces() { + assertEquals("GS", ArtworkGenerator.extractInitials(" Groove Salad ")) + } + + @Test + fun extractInitials_lowercaseInput() { + assertEquals("GS", ArtworkGenerator.extractInitials("groove salad")) + } + + @Test + fun pickColor_deterministic() { + val c1 = ArtworkGenerator.pickColor("Groove Salad") + val c2 = ArtworkGenerator.pickColor("Groove Salad") + assertEquals(c1, c2) + } + + @Test + fun pickColor_differentNamesCanDiffer() { + val c1 = ArtworkGenerator.pickColor("Groove Salad") + val c2 = ArtworkGenerator.pickColor("Drone Zone") + // Not guaranteed to differ (hash collisions) but the palette is broad enough + // that two very different strings are likely to map to different colors. + // This is a soft assertion -- we mainly verify no crash and determinism. + assertTrue(c1 != 0) + assertTrue(c2 != 0) + } + + @Test + fun pickColor_alwaysFromPalette() { + val palette = intArrayOf( + 0xFF1E88E5.toInt(), 0xFF43A047.toInt(), 0xFFE53935.toInt(), + 0xFF8E24AA.toInt(), 0xFFFB8C00.toInt(), 0xFF00ACC1.toInt(), + 0xFF3949AB.toInt(), 0xFF7CB342.toInt(), 0xFFD81B60.toInt(), + 0xFF5E35B1.toInt(), 0xFF039BE5.toInt(), 0xFFC0CA33.toInt(), + ) + val names = listOf( + "Alpha", "Bravo", "Charlie", "Delta", "Echo", + "Foxtrot", "Golf", "Hotel", "India", "Juliet", + "Kilo", "Lima", "Mike", "November", "Oscar" + ) + for (name in names) { + assertTrue( + "Color for '$name' should be in palette", + ArtworkGenerator.pickColor(name) in palette + ) + } + } +}