Compare commits
4 Commits
baf2bea3cf
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5929b86ee2
|
||
|
|
fd740929c1
|
||
|
|
ade0323eb8
|
||
|
|
54aa8ad43a
|
@@ -18,7 +18,7 @@ android {
|
|||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.1"
|
versionName = "1.3"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package xyz.cottongin.radio247.audio
|
package xyz.cottongin.radio247.audio
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Protocol
|
import okhttp3.Protocol
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import java.io.BufferedInputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.net.Socket
|
||||||
|
import java.net.URL
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
|
||||||
class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause)
|
class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||||
@@ -31,15 +35,28 @@ class StreamConnection(private val url: String) {
|
|||||||
var streamInfo: StreamInfo? = null
|
var streamInfo: StreamInfo? = null
|
||||||
private set
|
private set
|
||||||
private var response: Response? = null
|
private var response: Response? = null
|
||||||
|
private var rawSocket: Socket? = null
|
||||||
|
|
||||||
fun open() {
|
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()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.header("Icy-MetaData", "1")
|
.header("Icy-MetaData", "1")
|
||||||
.header("User-Agent", "Radio247/1.0")
|
.header("User-Agent", "Radio247/1.0")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
try {
|
|
||||||
val resp = client.newCall(request).execute()
|
val resp = client.newCall(request).execute()
|
||||||
if (!resp.isSuccessful) {
|
if (!resp.isSuccessful) {
|
||||||
resp.close()
|
resp.close()
|
||||||
@@ -54,8 +71,45 @@ class StreamConnection(private val url: String) {
|
|||||||
)
|
)
|
||||||
inputStream = resp.body?.byteStream()
|
inputStream = resp.body?.byteStream()
|
||||||
?: throw ConnectionFailed("Empty response body")
|
?: 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) {
|
} 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 {
|
try {
|
||||||
response?.close()
|
response?.close()
|
||||||
} catch (_: IOException) {}
|
} catch (_: IOException) {}
|
||||||
|
closeRawSocket()
|
||||||
response = null
|
response = null
|
||||||
inputStream = null
|
inputStream = null
|
||||||
metaint = null
|
metaint = null
|
||||||
streamInfo = 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")
|
@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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import org.junit.Assert.assertNull
|
|||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Assert.fail
|
import org.junit.Assert.fail
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.net.ProtocolException
|
||||||
|
|
||||||
class StreamConnectionTest {
|
class StreamConnectionTest {
|
||||||
|
|
||||||
@@ -120,4 +122,105 @@ class StreamConnectionTest {
|
|||||||
|
|
||||||
conn.close()
|
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