feat: add station album art management

Add ability to set, edit, clear cache, delete, and generate station
artwork. Users can pick images from gallery, paste URLs, or generate
initials-based avatars. Actions accessible from both the station context
menu and the edit dialog.

Made-with: Cursor
This commit is contained in:
cottongin
2026-04-27 22:25:18 -04:00
parent ade0323eb8
commit fd740929c1
10 changed files with 577 additions and 16 deletions

View File

@@ -54,4 +54,7 @@ interface StationDao {
@Query("UPDATE stations SET listenerCount = :count WHERE id = :id") @Query("UPDATE stations SET listenerCount = :count WHERE id = :id")
suspend fun updateListenerCount(id: Long, count: Int) suspend fun updateListenerCount(id: Long, count: Int)
@Query("UPDATE stations SET defaultArtworkUrl = :artworkUrl WHERE id = :id")
suspend fun updateArtworkUrl(id: Long, artworkUrl: String?)
} }

View File

@@ -11,7 +11,7 @@ import java.net.URLEncoder
class AlbumArtResolver( class AlbumArtResolver(
private val client: OkHttpClient, private val client: OkHttpClient,
private val artCache: ArtCache = ArtCache(), val artCache: ArtCache = ArtCache(),
private val musicBrainzBaseUrl: String = "https://musicbrainz.org", private val musicBrainzBaseUrl: String = "https://musicbrainz.org",
private val coverArtBaseUrl: String = "https://coverartarchive.org", private val coverArtBaseUrl: String = "https://coverartarchive.org",
private val skipArtVerification: Boolean = false, private val skipArtVerification: Boolean = false,

View File

@@ -14,4 +14,14 @@ class ArtCache(private val maxSize: Int = 500) {
fun put(key: String, url: String) { fun put(key: String, url: String) {
cache[key] = url cache[key] = url
} }
@Synchronized
fun removeByValue(url: String) {
cache.entries.removeAll { it.value == url }
}
@Synchronized
fun clear() {
cache.clear()
}
} }

View File

@@ -1,10 +1,23 @@
package xyz.cottongin.radio247.ui.screens.stationlist 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.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.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -13,23 +26,98 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import xyz.cottongin.radio247.R
@Composable @Composable
fun AddStationDialog( fun AddStationDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: (name: String, url: String) -> Unit, onConfirm: (name: String, url: String, artworkUrl: String?) -> Unit,
onPickImage: (onResult: (String) -> Unit) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") } var url by remember { mutableStateOf("") }
var artworkUrl by remember { mutableStateOf<String?>(null) }
var showUrlField by remember { mutableStateOf(false) }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Add Station") }, title = { Text("Add Station") },
text = { 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( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
@@ -51,7 +139,7 @@ fun AddStationDialog(
TextButton( TextButton(
onClick = { onClick = {
if (name.isNotBlank() && url.isNotBlank()) { if (name.isNotBlank() && url.isNotBlank()) {
onConfirm(name.trim(), url.trim()) onConfirm(name.trim(), url.trim(), artworkUrl?.trim())
onDismiss() onDismiss()
} }
} }

View File

@@ -1,10 +1,25 @@
package xyz.cottongin.radio247.ui.screens.stationlist 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.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.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -13,25 +28,109 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import xyz.cottongin.radio247.R
import xyz.cottongin.radio247.data.model.Station import xyz.cottongin.radio247.data.model.Station
@Composable @Composable
fun EditStationDialog( fun EditStationDialog(
station: Station, station: Station,
onDismiss: () -> Unit, 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 modifier: Modifier = Modifier
) { ) {
var name by remember(station.id) { mutableStateOf(station.name) } var name by remember(station.id) { mutableStateOf(station.name) }
var url by remember(station.id) { mutableStateOf(station.url) } 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( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Edit Station") }, title = { Text("Edit Station") },
text = { 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( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
@@ -53,7 +152,7 @@ fun EditStationDialog(
TextButton( TextButton(
onClick = { onClick = {
if (name.isNotBlank() && url.isNotBlank()) { if (name.isNotBlank() && url.isNotBlank()) {
onConfirm(name.trim(), url.trim()) onConfirm(name.trim(), url.trim(), artworkUrl?.trim())
onDismiss() onDismiss()
} }
} }

View File

@@ -107,6 +107,8 @@ fun StationListScreen(
var qualityStreams by remember { mutableStateOf<List<StationStream>>(emptyList()) } var qualityStreams by remember { mutableStateOf<List<StationStream>>(emptyList()) }
var qualityCurrentOrder by remember { mutableStateOf<List<String>>(emptyList()) } var qualityCurrentOrder by remember { mutableStateOf<List<String>>(emptyList()) }
var tabToRename by remember { mutableStateOf<TabInfo?>(null) } var tabToRename by remember { mutableStateOf<TabInfo?>(null) }
var artworkPickTarget by remember { mutableStateOf<Station?>(null) }
var addDialogArtCallback by remember { mutableStateOf<((String) -> Unit)?>(null) }
val importLauncher = rememberLauncherForActivityResult( val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(), 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 currentTab = viewState.tabs.getOrNull(viewState.selectedTabIndex)
val isBuiltInTab = currentTab?.isBuiltIn == true val isBuiltInTab = currentTab?.isBuiltIn == true
@@ -249,6 +274,13 @@ fun StationListScreen(
onEdit = { stationToEdit = station }, onEdit = { stationToEdit = station },
onDelete = { viewModel.deleteStation(station) }, onDelete = { viewModel.deleteStation(station) },
onToggleHidden = { viewModel.toggleHidden(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 = { onQuality = {
viewModel.getStreamsForStation(station.id) { streams -> viewModel.getStreamsForStation(station.id) { streams ->
viewModel.getQualityOverrideForStation(station.id) { json -> viewModel.getQualityOverrideForStation(station.id) { json ->
@@ -282,9 +314,13 @@ fun StationListScreen(
if (showAddStation) { if (showAddStation) {
AddStationDialog( AddStationDialog(
onDismiss = { showAddStation = false }, onDismiss = { showAddStation = false },
onConfirm = { name, url -> onConfirm = { name, url, artworkUrl ->
viewModel.addStation(name, url) viewModel.addStation(name, url, artworkUrl)
showAddStation = false showAddStation = false
},
onPickImage = { callback ->
addDialogArtCallback = callback
artworkPickerLauncher.launch("image/*")
} }
) )
} }
@@ -303,10 +339,17 @@ fun StationListScreen(
EditStationDialog( EditStationDialog(
station = station, station = station,
onDismiss = { stationToEdit = null }, onDismiss = { stationToEdit = null },
onConfirm = { name, url -> onConfirm = { name, url, artworkUrl ->
viewModel.updateStation(station, name, url) viewModel.updateStation(station, name, url, artworkUrl)
stationToEdit = null 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, onDelete: () -> Unit,
onToggleHidden: () -> Unit, onToggleHidden: () -> Unit,
onQuality: () -> Unit, onQuality: () -> Unit,
onSetArtwork: () -> Unit,
onClearArtCache: () -> Unit,
onRemoveArtwork: () -> Unit,
onGenerateArt: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var showMenu by remember { mutableStateOf(false) } 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()
}
)
}
}
} }
} }
} }

View File

@@ -1,6 +1,8 @@
package xyz.cottongin.radio247.ui.screens.stationlist package xyz.cottongin.radio247.ui.screens.stationlist
import android.app.Application import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope 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.Station
import xyz.cottongin.radio247.data.model.StationPreference import xyz.cottongin.radio247.data.model.StationPreference
import xyz.cottongin.radio247.data.model.StationStream import xyz.cottongin.radio247.data.model.StationStream
import xyz.cottongin.radio247.util.ArtworkGenerator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -26,6 +29,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
data class TabInfo( data class TabInfo(
val playlist: Playlist?, val playlist: Playlist?,
@@ -250,17 +254,55 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
viewModelScope.launch { stationDao.delete(station) } 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 currentTab = viewState.value.tabs.getOrNull(viewState.value.selectedTabIndex)
val playlistId = currentTab?.playlist?.id val playlistId = currentTab?.playlist?.id
viewModelScope.launch { 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 { 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())
} }
} }

View File

@@ -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()
}
}

View File

@@ -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"))
}
}

View File

@@ -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
)
}
}
}