Compare commits
4 Commits
baf2bea3cf
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5929b86ee2
|
||
|
|
fd740929c1
|
||
|
|
ade0323eb8
|
||
|
|
54aa8ad43a
|
@@ -18,7 +18,7 @@ android {
|
||||
minSdk = 28
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.1"
|
||||
versionName = "1.3"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package xyz.cottongin.radio247.audio
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.Socket
|
||||
import java.net.URL
|
||||
import java.time.Duration
|
||||
|
||||
class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
@@ -31,15 +35,28 @@ class StreamConnection(private val url: String) {
|
||||
var streamInfo: StreamInfo? = null
|
||||
private set
|
||||
private var response: Response? = null
|
||||
private var rawSocket: Socket? = null
|
||||
|
||||
fun open() {
|
||||
try {
|
||||
openWithOkHttp()
|
||||
} catch (e: IOException) {
|
||||
if (looksLikeIcyProtocolError(e)) {
|
||||
Log.i(TAG, "OkHttp rejected response (likely ICY protocol), falling back to raw socket")
|
||||
openWithRawSocket()
|
||||
} else {
|
||||
throw ConnectionFailed("Network error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openWithOkHttp() {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Icy-MetaData", "1")
|
||||
.header("User-Agent", "Radio247/1.0")
|
||||
.build()
|
||||
|
||||
try {
|
||||
val resp = client.newCall(request).execute()
|
||||
if (!resp.isSuccessful) {
|
||||
resp.close()
|
||||
@@ -54,8 +71,45 @@ class StreamConnection(private val url: String) {
|
||||
)
|
||||
inputStream = resp.body?.byteStream()
|
||||
?: throw ConnectionFailed("Empty response body")
|
||||
}
|
||||
|
||||
private fun openWithRawSocket() {
|
||||
try {
|
||||
val parsed = URL(url)
|
||||
val host = parsed.host ?: throw ConnectionFailed("No host in URL")
|
||||
val port = if (parsed.port != -1) parsed.port else parsed.defaultPort
|
||||
val path = if (parsed.path.isNullOrEmpty()) "/" else parsed.path
|
||||
|
||||
val socket = Socket(host, port)
|
||||
socket.soTimeout = 30_000
|
||||
rawSocket = socket
|
||||
|
||||
val out = socket.getOutputStream()
|
||||
val requestStr = "GET $path HTTP/1.0\r\n" +
|
||||
"Host: $host\r\n" +
|
||||
"User-Agent: Radio247/1.0\r\n" +
|
||||
"Icy-MetaData: 1\r\n" +
|
||||
"Accept: */*\r\n" +
|
||||
"\r\n"
|
||||
out.write(requestStr.toByteArray(Charsets.US_ASCII))
|
||||
out.flush()
|
||||
|
||||
val buffered = BufferedInputStream(socket.getInputStream(), 16384)
|
||||
val headers = parseIcyHeaders(buffered)
|
||||
|
||||
metaint = headers["icy-metaint"]?.toIntOrNull()
|
||||
streamInfo = StreamInfo(
|
||||
bitrate = headers["icy-br"]?.toIntOrNull(),
|
||||
ssl = url.startsWith("https", ignoreCase = true),
|
||||
contentType = headers["content-type"]
|
||||
)
|
||||
inputStream = buffered
|
||||
} catch (e: ConnectionFailed) {
|
||||
closeRawSocket()
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
throw ConnectionFailed("Network error", e)
|
||||
closeRawSocket()
|
||||
throw ConnectionFailed("Network error (raw socket)", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,9 +120,74 @@ class StreamConnection(private val url: String) {
|
||||
try {
|
||||
response?.close()
|
||||
} catch (_: IOException) {}
|
||||
closeRawSocket()
|
||||
response = null
|
||||
inputStream = null
|
||||
metaint = null
|
||||
streamInfo = null
|
||||
}
|
||||
|
||||
private fun closeRawSocket() {
|
||||
try {
|
||||
rawSocket?.close()
|
||||
} catch (_: IOException) {}
|
||||
rawSocket = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "StreamConnection"
|
||||
|
||||
/**
|
||||
* Shoutcast v1 servers respond with `ICY 200 OK` instead of a valid HTTP
|
||||
* status line. OkHttp surfaces this as a ProtocolException.
|
||||
*/
|
||||
internal fun looksLikeIcyProtocolError(e: IOException): Boolean {
|
||||
val msg = e.message ?: return false
|
||||
return msg.contains("unexpected status line", ignoreCase = true) ||
|
||||
msg.contains("http/0.9", ignoreCase = true) ||
|
||||
e.javaClass.simpleName == "ProtocolException"
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads headers line-by-line from an ICY response until the blank line.
|
||||
* Returns a map of lowercased header names to values. Throws
|
||||
* [ConnectionFailed] if the status line is not `ICY 200 OK` or `HTTP/1.x 200`.
|
||||
*/
|
||||
internal fun parseIcyHeaders(input: InputStream): Map<String, String> {
|
||||
val statusLine = readLine(input)
|
||||
if (!statusLine.startsWith("ICY ", ignoreCase = true) &&
|
||||
!statusLine.startsWith("HTTP/", ignoreCase = true)
|
||||
) {
|
||||
throw ConnectionFailed("Unexpected status line: $statusLine")
|
||||
}
|
||||
|
||||
val headers = mutableMapOf<String, String>()
|
||||
while (true) {
|
||||
val line = readLine(input)
|
||||
if (line.isEmpty()) break
|
||||
val colon = line.indexOf(':')
|
||||
if (colon > 0) {
|
||||
headers[line.substring(0, colon).trim().lowercase()] =
|
||||
line.substring(colon + 1).trim()
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
private fun readLine(input: InputStream): String {
|
||||
val sb = StringBuilder(128)
|
||||
while (true) {
|
||||
val b = input.read()
|
||||
if (b == -1) break
|
||||
if (b == '\n'.code) break
|
||||
if (b == '\r'.code) {
|
||||
input.mark(1)
|
||||
if (input.read() != '\n'.code) input.reset()
|
||||
break
|
||||
}
|
||||
sb.append(b.toChar())
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.net.ProtocolException
|
||||
|
||||
class StreamConnectionTest {
|
||||
|
||||
@@ -120,4 +122,105 @@ class StreamConnectionTest {
|
||||
|
||||
conn.close()
|
||||
}
|
||||
|
||||
// --- ICY protocol header parsing (Shoutcast v1 support) ---
|
||||
|
||||
@Test
|
||||
fun parseIcyHeaders_extracts_standard_shoutcast_headers() {
|
||||
val raw = "ICY 200 OK\r\n" +
|
||||
"icy-notice1:<BR>This stream requires Winamp<BR>\r\n" +
|
||||
"icy-name:My Station\r\n" +
|
||||
"icy-genre:Rock\r\n" +
|
||||
"content-type:audio/mpeg\r\n" +
|
||||
"icy-pub:1\r\n" +
|
||||
"icy-metaint:8192\r\n" +
|
||||
"icy-br:128\r\n" +
|
||||
"\r\n" +
|
||||
"audio bytes here"
|
||||
val input = ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII))
|
||||
|
||||
val headers = StreamConnection.parseIcyHeaders(input)
|
||||
|
||||
assertEquals("8192", headers["icy-metaint"])
|
||||
assertEquals("128", headers["icy-br"])
|
||||
assertEquals("audio/mpeg", headers["content-type"])
|
||||
assertEquals("My Station", headers["icy-name"])
|
||||
assertEquals("Rock", headers["icy-genre"])
|
||||
|
||||
val remaining = input.readBytes().toString(Charsets.US_ASCII)
|
||||
assertEquals("audio bytes here", remaining)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseIcyHeaders_accepts_http_status_line() {
|
||||
val raw = "HTTP/1.0 200 OK\r\n" +
|
||||
"icy-metaint:16000\r\n" +
|
||||
"Content-Type:audio/mpeg\r\n" +
|
||||
"\r\n"
|
||||
val headers = StreamConnection.parseIcyHeaders(
|
||||
ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII))
|
||||
)
|
||||
assertEquals("16000", headers["icy-metaint"])
|
||||
assertEquals("audio/mpeg", headers["content-type"])
|
||||
}
|
||||
|
||||
@Test(expected = ConnectionFailed::class)
|
||||
fun parseIcyHeaders_rejects_garbage_status_line() {
|
||||
val raw = "GARBAGE\r\n\r\n"
|
||||
StreamConnection.parseIcyHeaders(
|
||||
ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseIcyHeaders_handles_missing_metaint() {
|
||||
val raw = "ICY 200 OK\r\n" +
|
||||
"content-type:audio/mpeg\r\n" +
|
||||
"icy-br:64\r\n" +
|
||||
"\r\n"
|
||||
val headers = StreamConnection.parseIcyHeaders(
|
||||
ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII))
|
||||
)
|
||||
assertNull(headers["icy-metaint"])
|
||||
assertEquals("64", headers["icy-br"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseIcyHeaders_handles_lf_only_line_endings() {
|
||||
val raw = "ICY 200 OK\n" +
|
||||
"icy-metaint:4096\n" +
|
||||
"content-type:audio/mpeg\n" +
|
||||
"\n"
|
||||
val headers = StreamConnection.parseIcyHeaders(
|
||||
ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII))
|
||||
)
|
||||
assertEquals("4096", headers["icy-metaint"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun looksLikeIcyProtocolError_matches_protocol_exception() {
|
||||
assertTrue(
|
||||
StreamConnection.looksLikeIcyProtocolError(
|
||||
ProtocolException("unexpected status line: ICY 200 OK")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun looksLikeIcyProtocolError_matches_http09_message() {
|
||||
assertTrue(
|
||||
StreamConnection.looksLikeIcyProtocolError(
|
||||
java.io.IOException("Received HTTP/0.9 when not allowed")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun looksLikeIcyProtocolError_does_not_match_unrelated_io_error() {
|
||||
assertTrue(
|
||||
!StreamConnection.looksLikeIcyProtocolError(
|
||||
java.io.IOException("Connection reset by peer")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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