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