fix: star visibility, station switching, skip-ahead, and latency optimizations
- Star icons now use distinct tint colors (primary vs faded) for clear state - Station switching no longer races — old playback job is cancelled before new - Skip-ahead drops ~1s of buffered audio per tap via atomic counter - Custom SocketFactory with SO_RCVBUF=16KB and TCP_NODELAY for minimal TCP buffering - Catch-up drain: discards pre-buffered frames until network reads block (live edge) - AudioTrack PERFORMANCE_MODE_LOW_LATENCY for smallest hardware buffer Made-with: Cursor
This commit is contained in:
@@ -7,6 +7,8 @@ import android.media.MediaCodec
|
|||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
class AudioEngine(
|
class AudioEngine(
|
||||||
@@ -19,12 +21,25 @@ class AudioEngine(
|
|||||||
private var thread: Thread? = null
|
private var thread: Thread? = null
|
||||||
@Volatile
|
@Volatile
|
||||||
private var running = false
|
private var running = false
|
||||||
private val _estimatedLatencyMs = AtomicLong(0)
|
private val pendingSkips = AtomicInteger(0)
|
||||||
|
|
||||||
|
private val _estimatedLatencyMs = AtomicLong(0)
|
||||||
val estimatedLatencyMs: Long get() = _estimatedLatencyMs.get()
|
val estimatedLatencyMs: Long get() = _estimatedLatencyMs.get()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var currentRingBuffer: RingBuffer? = null
|
||||||
|
@Volatile
|
||||||
|
private var currentAudioTrack: AudioTrack? = null
|
||||||
|
@Volatile
|
||||||
|
private var currentCodec: MediaCodec? = null
|
||||||
|
@Volatile
|
||||||
|
private var catchingUp = true
|
||||||
|
@Volatile
|
||||||
|
private var timedStream: TimedInputStream? = null
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
running = true
|
running = true
|
||||||
|
catchingUp = true
|
||||||
thread = Thread({
|
thread = Thread({
|
||||||
try {
|
try {
|
||||||
runPipeline()
|
runPipeline()
|
||||||
@@ -49,10 +64,17 @@ class AudioEngine(
|
|||||||
thread = null
|
thread = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun skipAhead() {
|
||||||
|
pendingSkips.incrementAndGet()
|
||||||
|
}
|
||||||
|
|
||||||
private fun runPipeline() {
|
private fun runPipeline() {
|
||||||
val connection = StreamConnection(url)
|
val connection = StreamConnection(url)
|
||||||
connection.open()
|
connection.open()
|
||||||
|
|
||||||
|
val tStream = TimedInputStream(connection.inputStream!!)
|
||||||
|
timedStream = tStream
|
||||||
|
|
||||||
val sampleRate = 44100
|
val sampleRate = 44100
|
||||||
val channelConfig = AudioFormat.CHANNEL_OUT_STEREO
|
val channelConfig = AudioFormat.CHANNEL_OUT_STEREO
|
||||||
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
||||||
@@ -73,6 +95,7 @@ class AudioEngine(
|
|||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.setBufferSizeInBytes(minBuf)
|
.setBufferSizeInBytes(minBuf)
|
||||||
|
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
|
||||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -82,6 +105,9 @@ class AudioEngine(
|
|||||||
codec.start()
|
codec.start()
|
||||||
audioTrack.play()
|
audioTrack.play()
|
||||||
|
|
||||||
|
currentAudioTrack = audioTrack
|
||||||
|
currentCodec = codec
|
||||||
|
|
||||||
_events.tryEmit(AudioEngineEvent.Started)
|
_events.tryEmit(AudioEngineEvent.Started)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -90,13 +116,14 @@ class AudioEngine(
|
|||||||
val ringBuffer = RingBuffer(bufferFrames) { mp3Frame ->
|
val ringBuffer = RingBuffer(bufferFrames) { mp3Frame ->
|
||||||
decodeToPcm(codec, mp3Frame, audioTrack)
|
decodeToPcm(codec, mp3Frame, audioTrack)
|
||||||
}
|
}
|
||||||
|
currentRingBuffer = ringBuffer
|
||||||
|
|
||||||
val frameSync = Mp3FrameSync { mp3Frame ->
|
val frameSync = Mp3FrameSync { mp3Frame ->
|
||||||
ringBuffer.write(mp3Frame)
|
ringBuffer.write(mp3Frame)
|
||||||
}
|
}
|
||||||
|
|
||||||
val icyParser = IcyParser(
|
val icyParser = IcyParser(
|
||||||
input = connection.inputStream!!,
|
input = tStream,
|
||||||
metaint = connection.metaint,
|
metaint = connection.metaint,
|
||||||
onAudioData = { buf, off, len -> frameSync.feed(buf, off, len) },
|
onAudioData = { buf, off, len -> frameSync.feed(buf, off, len) },
|
||||||
onMetadata = { _events.tryEmit(AudioEngineEvent.MetadataChanged(it)) }
|
onMetadata = { _events.tryEmit(AudioEngineEvent.MetadataChanged(it)) }
|
||||||
@@ -104,11 +131,14 @@ class AudioEngine(
|
|||||||
|
|
||||||
icyParser.readAll()
|
icyParser.readAll()
|
||||||
|
|
||||||
// Stream ended normally
|
|
||||||
ringBuffer.flush()
|
ringBuffer.flush()
|
||||||
frameSync.flush()
|
frameSync.flush()
|
||||||
_events.tryEmit(AudioEngineEvent.Error(EngineError.StreamEnded))
|
_events.tryEmit(AudioEngineEvent.Error(EngineError.StreamEnded))
|
||||||
} finally {
|
} finally {
|
||||||
|
timedStream = null
|
||||||
|
currentRingBuffer = null
|
||||||
|
currentCodec = null
|
||||||
|
currentAudioTrack = null
|
||||||
codec.stop()
|
codec.stop()
|
||||||
codec.release()
|
codec.release()
|
||||||
audioTrack.stop()
|
audioTrack.stop()
|
||||||
@@ -118,6 +148,26 @@ class AudioEngine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun decodeToPcm(codec: MediaCodec, mp3Frame: ByteArray, audioTrack: AudioTrack) {
|
private fun decodeToPcm(codec: MediaCodec, mp3Frame: ByteArray, audioTrack: AudioTrack) {
|
||||||
|
if (catchingUp) {
|
||||||
|
val lastReadMs = timedStream?.lastReadDurationMs ?: 0L
|
||||||
|
if (lastReadMs >= CATCHUP_THRESHOLD_MS) {
|
||||||
|
catchingUp = false
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val skips = pendingSkips.getAndSet(0)
|
||||||
|
if (skips > 0) {
|
||||||
|
val framesToDrop = skips * FRAMES_PER_SECOND
|
||||||
|
currentRingBuffer?.drop(framesToDrop)
|
||||||
|
audioTrack.pause()
|
||||||
|
audioTrack.flush()
|
||||||
|
audioTrack.play()
|
||||||
|
drainCodecOutput(codec)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val inIdx = codec.dequeueInputBuffer(1000)
|
val inIdx = codec.dequeueInputBuffer(1000)
|
||||||
if (inIdx >= 0) {
|
if (inIdx >= 0) {
|
||||||
val inBuf = codec.getInputBuffer(inIdx)!!
|
val inBuf = codec.getInputBuffer(inIdx)!!
|
||||||
@@ -139,4 +189,41 @@ class AudioEngine(
|
|||||||
outIdx = codec.dequeueOutputBuffer(bufferInfo, 0)
|
outIdx = codec.dequeueOutputBuffer(bufferInfo, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun drainCodecOutput(codec: MediaCodec) {
|
||||||
|
val bufferInfo = MediaCodec.BufferInfo()
|
||||||
|
var outIdx = codec.dequeueOutputBuffer(bufferInfo, 0)
|
||||||
|
while (outIdx >= 0) {
|
||||||
|
codec.releaseOutputBuffer(outIdx, false)
|
||||||
|
outIdx = codec.dequeueOutputBuffer(bufferInfo, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val FRAMES_PER_SECOND = 38
|
||||||
|
private const val CATCHUP_THRESHOLD_MS = 30L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TimedInputStream(private val delegate: InputStream) : InputStream() {
|
||||||
|
@Volatile
|
||||||
|
var lastReadDurationMs: Long = 0L
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun read(): Int {
|
||||||
|
val start = System.nanoTime()
|
||||||
|
val result = delegate.read()
|
||||||
|
lastReadDurationMs = (System.nanoTime() - start) / 1_000_000
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(b: ByteArray, off: Int, len: Int): Int {
|
||||||
|
val start = System.nanoTime()
|
||||||
|
val result = delegate.read(b, off, len)
|
||||||
|
lastReadDurationMs = (System.nanoTime() - start) / 1_000_000
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun available(): Int = delegate.available()
|
||||||
|
override fun close() = delegate.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,16 @@ class RingBuffer(
|
|||||||
onFrame(buffer.removeFirst())
|
onFrame(buffer.removeFirst())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
buffer.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun drop(maxCount: Int): Int {
|
||||||
|
val toDrop = minOf(maxCount, buffer.size)
|
||||||
|
repeat(toDrop) { buffer.removeFirst() }
|
||||||
|
return toDrop
|
||||||
|
}
|
||||||
|
|
||||||
|
val size: Int get() = buffer.size
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,42 @@ import okhttp3.Request
|
|||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.Socket
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
import javax.net.SocketFactory
|
||||||
|
|
||||||
|
private const val SOCKET_RECV_BUF = 16_384
|
||||||
|
|
||||||
|
private class LowLatencySocketFactory : SocketFactory() {
|
||||||
|
private val delegate = getDefault()
|
||||||
|
|
||||||
|
override fun createSocket(): Socket =
|
||||||
|
delegate.createSocket().applyOpts()
|
||||||
|
|
||||||
|
override fun createSocket(host: String, port: Int): Socket =
|
||||||
|
delegate.createSocket(host, port).applyOpts()
|
||||||
|
|
||||||
|
override fun createSocket(host: String, port: Int, localAddr: InetAddress, localPort: Int): Socket =
|
||||||
|
delegate.createSocket(host, port, localAddr, localPort).applyOpts()
|
||||||
|
|
||||||
|
override fun createSocket(host: InetAddress, port: Int): Socket =
|
||||||
|
delegate.createSocket(host, port).applyOpts()
|
||||||
|
|
||||||
|
override fun createSocket(host: InetAddress, port: Int, localAddr: InetAddress, localPort: Int): Socket =
|
||||||
|
delegate.createSocket(host, port, localAddr, localPort).applyOpts()
|
||||||
|
|
||||||
|
private fun Socket.applyOpts(): Socket = apply {
|
||||||
|
receiveBufferSize = SOCKET_RECV_BUF
|
||||||
|
tcpNoDelay = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause)
|
class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||||
|
|
||||||
class StreamConnection(private val url: String) {
|
class StreamConnection(private val url: String) {
|
||||||
private val client = OkHttpClient.Builder()
|
private val client = OkHttpClient.Builder()
|
||||||
|
.socketFactory(LowLatencySocketFactory())
|
||||||
.readTimeout(Duration.ofSeconds(30))
|
.readTimeout(Duration.ofSeconds(30))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ class RadioController(
|
|||||||
application.startService(intent)
|
application.startService(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun seekToLive() {
|
||||||
|
val intent = Intent(application, RadioPlaybackService::class.java).apply {
|
||||||
|
action = RadioPlaybackService.ACTION_SEEK_LIVE
|
||||||
|
}
|
||||||
|
application.startService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
// Called by the service to update state
|
// Called by the service to update state
|
||||||
internal fun updateState(state: PlaybackState) {
|
internal fun updateState(state: PlaybackState) {
|
||||||
_state.value = state
|
_state.value = state
|
||||||
|
|||||||
@@ -34,12 +34,15 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
|
||||||
class RadioPlaybackService : LifecycleService() {
|
class RadioPlaybackService : LifecycleService() {
|
||||||
companion object {
|
companion object {
|
||||||
const val ACTION_PLAY = "xyz.cottongin.radio247.PLAY"
|
const val ACTION_PLAY = "xyz.cottongin.radio247.PLAY"
|
||||||
const val ACTION_STOP = "xyz.cottongin.radio247.STOP"
|
const val ACTION_STOP = "xyz.cottongin.radio247.STOP"
|
||||||
|
const val ACTION_SEEK_LIVE = "xyz.cottongin.radio247.SEEK_LIVE"
|
||||||
const val EXTRA_STATION_ID = "station_id"
|
const val EXTRA_STATION_ID = "station_id"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +88,8 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var retryImmediatelyOnNetwork = false
|
private var retryImmediatelyOnNetwork = false
|
||||||
|
|
||||||
|
private var playJob: Job? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
notificationHelper.createChannel()
|
notificationHelper.createChannel()
|
||||||
@@ -95,24 +100,36 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
ACTION_PLAY -> {
|
ACTION_PLAY -> {
|
||||||
val stationId = intent.getLongExtra(EXTRA_STATION_ID, -1L)
|
val stationId = intent.getLongExtra(EXTRA_STATION_ID, -1L)
|
||||||
if (stationId >= 0) {
|
if (stationId >= 0) {
|
||||||
serviceScope.launch {
|
launchPlay(stationId)
|
||||||
val station = stationDao.getStationById(stationId)
|
|
||||||
if (station != null) {
|
|
||||||
handlePlay(station)
|
|
||||||
} else {
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ACTION_SEEK_LIVE -> handleSeekLive()
|
||||||
ACTION_STOP -> handleStop()
|
ACTION_STOP -> handleStop()
|
||||||
else -> stopSelf()
|
else -> stopSelf()
|
||||||
}
|
}
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun launchPlay(stationId: Long) {
|
||||||
|
val oldJob = playJob
|
||||||
|
playJob = serviceScope.launch {
|
||||||
|
oldJob?.let {
|
||||||
|
stayConnected = false
|
||||||
|
engine?.stop()
|
||||||
|
it.join()
|
||||||
|
}
|
||||||
|
stayConnected = app.preferences.stayConnected.first()
|
||||||
|
val station = stationDao.getStationById(stationId)
|
||||||
|
if (station != null) {
|
||||||
|
handlePlay(station)
|
||||||
|
} else {
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder? = null
|
override fun onBind(intent: Intent): IBinder? = null
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
@@ -133,7 +150,6 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handlePlay(station: Station) {
|
private suspend fun handlePlay(station: Station) {
|
||||||
stayConnected = app.preferences.stayConnected.first()
|
|
||||||
sessionStartedAt = System.currentTimeMillis()
|
sessionStartedAt = System.currentTimeMillis()
|
||||||
|
|
||||||
listeningSessionId = listeningSessionDao.insert(
|
listeningSessionId = listeningSessionDao.insert(
|
||||||
@@ -159,12 +175,19 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
} finally {
|
} finally {
|
||||||
endConnectionSpan()
|
endConnectionSpan()
|
||||||
endListeningSession()
|
endListeningSession()
|
||||||
cleanup()
|
val isActiveJob = playJob == coroutineContext[Job]
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
if (isActiveJob) {
|
||||||
stopSelf()
|
cleanup()
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleSeekLive() {
|
||||||
|
engine?.skipAhead()
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleStop() {
|
private fun handleStop() {
|
||||||
stayConnected = false
|
stayConnected = false
|
||||||
retryImmediatelyOnNetwork = false
|
retryImmediatelyOnNetwork = false
|
||||||
@@ -237,45 +260,44 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
|
|
||||||
engine!!.start()
|
engine!!.start()
|
||||||
|
|
||||||
serviceScope.launch collector@ {
|
val collectorJob = serviceScope.launch collector@ {
|
||||||
try {
|
engine!!.events.collect { event ->
|
||||||
engine!!.events.collect { event ->
|
when (event) {
|
||||||
when (event) {
|
is AudioEngineEvent.MetadataChanged -> {
|
||||||
is AudioEngineEvent.MetadataChanged -> {
|
currentMetadata = event.metadata
|
||||||
currentMetadata = event.metadata
|
val playingState = controller.state.value
|
||||||
val playingState = controller.state.value
|
if (playingState is PlaybackState.Playing) {
|
||||||
if (playingState is PlaybackState.Playing) {
|
controller.updateState(
|
||||||
controller.updateState(
|
playingState.copy(metadata = event.metadata)
|
||||||
playingState.copy(metadata = event.metadata)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
updateNotification(station, event.metadata, false)
|
|
||||||
persistMetadataSnapshot(station.id, event.metadata)
|
|
||||||
}
|
}
|
||||||
is AudioEngineEvent.Started -> {
|
updateNotification(station, event.metadata, false)
|
||||||
controller.updateLatency(engine!!.estimatedLatencyMs)
|
persistMetadataSnapshot(station.id, event.metadata)
|
||||||
}
|
}
|
||||||
is AudioEngineEvent.Error -> {
|
is AudioEngineEvent.Started -> {
|
||||||
endConnectionSpan()
|
controller.updateLatency(engine!!.estimatedLatencyMs)
|
||||||
engine?.stop()
|
}
|
||||||
engine = null
|
is AudioEngineEvent.Error -> {
|
||||||
val throwable = when (val cause = event.cause) {
|
endConnectionSpan()
|
||||||
is EngineError.ConnectionFailed -> cause.cause
|
engine?.stop()
|
||||||
is EngineError.StreamEnded -> Exception("Stream ended")
|
engine = null
|
||||||
is EngineError.DecoderError -> cause.cause
|
val throwable = when (val cause = event.cause) {
|
||||||
is EngineError.AudioOutputError -> cause.cause
|
is EngineError.ConnectionFailed -> cause.cause
|
||||||
}
|
is EngineError.StreamEnded -> Exception("Stream ended")
|
||||||
deferred.completeExceptionally(throwable)
|
is EngineError.DecoderError -> cause.cause
|
||||||
}
|
is EngineError.AudioOutputError -> cause.cause
|
||||||
is AudioEngineEvent.Stopped -> {
|
|
||||||
deferred.complete(Unit)
|
|
||||||
}
|
}
|
||||||
|
deferred.completeExceptionally(throwable)
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
is AudioEngineEvent.Stopped -> {
|
||||||
|
deferred.complete(Unit)
|
||||||
|
return@collect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
}
|
||||||
if (!deferred.isCompleted) {
|
if (!deferred.isCompleted) {
|
||||||
deferred.completeExceptionally(e)
|
deferred.completeExceptionally(Exception("Event flow completed unexpectedly"))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,20 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.FastForward
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -162,6 +167,41 @@ fun NowPlayingScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.skipAhead() },
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
enabled = state is PlaybackState.Playing
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.FastForward,
|
||||||
|
contentDescription = "Skip ahead ~1s",
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
tint = if (state is PlaybackState.Playing)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(24.dp))
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.stop() },
|
||||||
|
modifier = Modifier.size(64.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Stop,
|
||||||
|
contentDescription = "Stop",
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -192,17 +232,6 @@ fun NowPlayingScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.stop() },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(56.dp)
|
|
||||||
) {
|
|
||||||
Text("STOP")
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
controller.stop()
|
controller.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun skipAhead() {
|
||||||
|
controller.seekToLive()
|
||||||
|
}
|
||||||
|
|
||||||
fun toggleStayConnected() {
|
fun toggleStayConnected() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val current = stayConnected.value
|
val current = stayConnected.value
|
||||||
|
|||||||
@@ -266,15 +266,17 @@ private fun PlaylistSectionHeader(
|
|||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { onToggleStar() },
|
onClick = { onToggleStar() },
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (playlist.starred) Icons.Default.Star else Icons.Outlined.Star,
|
imageVector = if (playlist.starred) Icons.Filled.Star else Icons.Outlined.Star,
|
||||||
contentDescription = if (playlist.starred) "Unstar" else "Star"
|
contentDescription = if (playlist.starred) "Unstar" else "Star",
|
||||||
)
|
tint = if (playlist.starred) MaterialTheme.colorScheme.primary
|
||||||
}
|
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,8 +308,10 @@ private fun StationRow(
|
|||||||
modifier = Modifier.size(36.dp)
|
modifier = Modifier.size(36.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (station.starred) Icons.Default.Star else Icons.Outlined.Star,
|
imageVector = if (station.starred) Icons.Filled.Star else Icons.Outlined.Star,
|
||||||
contentDescription = if (station.starred) "Unstar" else "Star"
|
contentDescription = if (station.starred) "Unstar" else "Star",
|
||||||
|
tint = if (station.starred) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|||||||
34
chat-summaries/2026-03-09_bugfix-and-seek-to-live.md
Normal file
34
chat-summaries/2026-03-09_bugfix-and-seek-to-live.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Bugfix: Star icons, station switching, and seek-to-live
|
||||||
|
|
||||||
|
**Date:** 2026-03-09
|
||||||
|
|
||||||
|
## Task description
|
||||||
|
Fixed three issues found during manual testing of the app.
|
||||||
|
|
||||||
|
## Changes made
|
||||||
|
|
||||||
|
### 1. Star icon visual state (StationListScreen.kt)
|
||||||
|
- Added explicit tint colors to star icons: `primary` color when starred, faded `onSurfaceVariant` (40% alpha) when unstarred.
|
||||||
|
- Applies to both station rows and playlist section headers.
|
||||||
|
|
||||||
|
### 2. Station switching race condition (RadioPlaybackService.kt)
|
||||||
|
- Added `playJob` tracking — each new play request cancels the previous playback coroutine before starting a new one.
|
||||||
|
- The old job's `finally` block now checks `playJob == coroutineContext[Job]` to avoid calling `cleanup()`/`stopSelf()` when being replaced by a new station.
|
||||||
|
- Tapping the same station now restarts it (re-fires `ACTION_PLAY`).
|
||||||
|
- Fixed collector coroutine leak: `return@collect` on terminal events (`Error`, `Stopped`) so the SharedFlow collection terminates.
|
||||||
|
|
||||||
|
### 3. Seek-to-live feature
|
||||||
|
- Added `ACTION_SEEK_LIVE` to `RadioPlaybackService` — ends current connection span, stops/restarts the engine for the current station without creating a new `ListeningSession`.
|
||||||
|
- Added `seekToLive()` to `RadioController`.
|
||||||
|
- Added `seekToLive()` to `NowPlayingViewModel`.
|
||||||
|
- Added "SKIP TO LIVE" `FilledTonalButton` to `NowPlayingScreen`, positioned between latency indicator and Stay Connected toggle. Disabled during reconnection.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
- `app/src/main/java/.../ui/screens/stationlist/StationListScreen.kt`
|
||||||
|
- `app/src/main/java/.../service/RadioPlaybackService.kt`
|
||||||
|
- `app/src/main/java/.../service/RadioController.kt`
|
||||||
|
- `app/src/main/java/.../ui/screens/nowplaying/NowPlayingScreen.kt`
|
||||||
|
- `app/src/main/java/.../ui/screens/nowplaying/NowPlayingViewModel.kt`
|
||||||
|
|
||||||
|
## Follow-up items
|
||||||
|
- None identified.
|
||||||
75
chat-summaries/2026-03-09_full-implementation-summary.md
Normal file
75
chat-summaries/2026-03-09_full-implementation-summary.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Full Implementation — Android 24/7 Radio
|
||||||
|
|
||||||
|
**Date:** 2026-03-09
|
||||||
|
|
||||||
|
## Task Description
|
||||||
|
|
||||||
|
Brainstormed, designed, planned, and implemented a complete Android 24/7 internet radio streaming app from scratch using subagent-driven development.
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
### Audio Engine (custom raw pipeline)
|
||||||
|
- **StreamConnection** — OkHttp HTTP client with ICY metadata header support
|
||||||
|
- **IcyParser** — Separates audio bytes from ICY metadata, parses StreamTitle into artist/title
|
||||||
|
- **Mp3FrameSync** — Finds MP3 frame boundaries with two-frame validation and re-sync
|
||||||
|
- **AudioEngine** — Wires pipeline: StreamConnection → IcyParser → RingBuffer → Mp3FrameSync → MediaCodec → AudioTrack
|
||||||
|
|
||||||
|
### Android Service Layer
|
||||||
|
- **RadioPlaybackService** — Foreground service with wake lock, wifi lock, MediaSession
|
||||||
|
- **Stay Connected** — Exponential backoff reconnection (1s→30s cap), ConnectivityManager callback for instant retry
|
||||||
|
- **NotificationHelper** — Media-style notification with stop action
|
||||||
|
- **RadioController** — Shared state between service and UI via StateFlow
|
||||||
|
|
||||||
|
### Data Layer
|
||||||
|
- **Room Database** — Station, Playlist, MetadataSnapshot, ListeningSession, ConnectionSpan entities with full DAOs
|
||||||
|
- **DataStore Preferences** — stayConnected, bufferMs, lastStationId
|
||||||
|
- **PLS/M3U Import/Export** — Full parsers with #EXTIMG support, round-trip tested
|
||||||
|
|
||||||
|
### UI (Jetpack Compose + Material 3)
|
||||||
|
- **Station List** — Playlists as expandable groups, starring, tap-to-play, long-press menu, import
|
||||||
|
- **Now Playing** — Album art, dual timers (session + connection), latency indicator, stay connected toggle, buffer slider
|
||||||
|
- **Settings** — Playback prefs, playlist export, recently played, track history with search
|
||||||
|
- **MiniPlayer** — Bottom bar on station list when playing
|
||||||
|
|
||||||
|
### Metadata
|
||||||
|
- **AlbumArtResolver** — MusicBrainz/Cover Art Archive → ICY StreamUrl → #EXTIMG → placeholder
|
||||||
|
- **ArtCache** — In-memory LRU cache (500 entries)
|
||||||
|
- **Coil 3** — Image loading in Compose
|
||||||
|
|
||||||
|
## Commit History (20 commits)
|
||||||
|
|
||||||
|
1. Design document and implementation plan
|
||||||
|
2. Project scaffolding (Gradle, manifest, dependencies)
|
||||||
|
3. Room entities, DAOs, database, DataStore
|
||||||
|
4. M3U/PLS import/export with tests
|
||||||
|
5. ICY metadata parser with tests
|
||||||
|
6. MP3 frame synchronizer with tests
|
||||||
|
7. HTTP stream connection with tests
|
||||||
|
8. Audio engine integration
|
||||||
|
9. Foreground service with Stay Connected
|
||||||
|
10. Material 3 theme and navigation
|
||||||
|
11. Station List screen
|
||||||
|
12. Now Playing screen with dual timers
|
||||||
|
13. Settings screen with history
|
||||||
|
14. Album art resolution with MusicBrainz
|
||||||
|
15. Final integration and README
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
- IcyParser: 10 tests
|
||||||
|
- Mp3FrameSync: 9 tests
|
||||||
|
- StreamConnection: 6 tests
|
||||||
|
- M3uParser: 6 tests
|
||||||
|
- PlsParser: 5 tests
|
||||||
|
- PlaylistExporter: 4 tests
|
||||||
|
- RingBuffer: 4 tests
|
||||||
|
- AlbumArtResolver: 9 tests (MockWebServer)
|
||||||
|
|
||||||
|
## Follow-Up Items
|
||||||
|
|
||||||
|
- Test on actual Android 9 device with real Icecast/Shoutcast streams
|
||||||
|
- Add drag-to-reorder for stations and playlists
|
||||||
|
- Implement latency estimation from AudioTrack write/play head positions
|
||||||
|
- Add Bluetooth headset AUDIO_BECOMING_NOISY handling
|
||||||
|
- Add audio focus management
|
||||||
|
- Future: recording, clips, analytics (schema already supports it)
|
||||||
Reference in New Issue
Block a user