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:
@@ -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?)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String?>(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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,8 @@ fun StationListScreen(
|
||||
var qualityStreams by remember { mutableStateOf<List<StationStream>>(emptyList()) }
|
||||
var qualityCurrentOrder by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
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(
|
||||
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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user