feat: now playing UX overhaul with stream quality and audio improvements

Redesign Now Playing screen with blurred album art background, dominant
color extraction, bounce marquee for long text, cross-fade artwork
transitions, icon-labeled timers, and stream quality badge (bitrate,
codec, SSL). Add StreamInfo propagation from connection through to UI.
Fix MediaCodec PTS spam by providing incrementing presentation timestamps.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 20:16:43 -04:00
parent 6481d74d95
commit c0ba23b208
8 changed files with 1139 additions and 257 deletions

View File

@@ -36,10 +36,12 @@ class AudioEngine(
private var catchingUp = true private var catchingUp = true
@Volatile @Volatile
private var timedStream: TimedInputStream? = null private var timedStream: TimedInputStream? = null
private var presentationTimeUs = 0L
fun start() { fun start() {
running = true running = true
catchingUp = true catchingUp = true
presentationTimeUs = 0L
thread = Thread({ thread = Thread({
try { try {
runPipeline() runPipeline()
@@ -109,6 +111,7 @@ class AudioEngine(
currentCodec = codec currentCodec = codec
_events.tryEmit(AudioEngineEvent.Started) _events.tryEmit(AudioEngineEvent.Started)
connection.streamInfo?.let { _events.tryEmit(AudioEngineEvent.StreamInfoReceived(it)) }
try { try {
val bufferFrames = if (bufferMs > 0) (bufferMs / 26).coerceAtLeast(1) else 0 val bufferFrames = if (bufferMs > 0) (bufferMs / 26).coerceAtLeast(1) else 0
@@ -165,6 +168,7 @@ class AudioEngine(
audioTrack.flush() audioTrack.flush()
audioTrack.play() audioTrack.play()
drainCodecOutput(codec) drainCodecOutput(codec)
presentationTimeUs = 0L
return return
} }
@@ -173,7 +177,8 @@ class AudioEngine(
val inBuf = codec.getInputBuffer(inIdx)!! val inBuf = codec.getInputBuffer(inIdx)!!
inBuf.clear() inBuf.clear()
inBuf.put(mp3Frame) inBuf.put(mp3Frame)
codec.queueInputBuffer(inIdx, 0, mp3Frame.size, 0, 0) codec.queueInputBuffer(inIdx, 0, mp3Frame.size, presentationTimeUs, 0)
presentationTimeUs += FRAME_DURATION_US
} }
val bufferInfo = MediaCodec.BufferInfo() val bufferInfo = MediaCodec.BufferInfo()
@@ -202,6 +207,7 @@ class AudioEngine(
companion object { companion object {
private const val FRAMES_PER_SECOND = 38 private const val FRAMES_PER_SECOND = 38
private const val CATCHUP_THRESHOLD_MS = 30L private const val CATCHUP_THRESHOLD_MS = 30L
private const val FRAME_DURATION_US = 26_122L // 1152 samples at 44100 Hz
} }
} }

View File

@@ -2,6 +2,7 @@ package xyz.cottongin.radio247.audio
sealed interface AudioEngineEvent { sealed interface AudioEngineEvent {
data class MetadataChanged(val metadata: IcyMetadata) : AudioEngineEvent data class MetadataChanged(val metadata: IcyMetadata) : AudioEngineEvent
data class StreamInfoReceived(val streamInfo: StreamInfo) : AudioEngineEvent
data class Error(val cause: EngineError) : AudioEngineEvent data class Error(val cause: EngineError) : AudioEngineEvent
data object Started : AudioEngineEvent data object Started : AudioEngineEvent
data object Stopped : AudioEngineEvent data object Stopped : AudioEngineEvent

View File

@@ -38,6 +38,12 @@ private class LowLatencySocketFactory : SocketFactory() {
class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause) class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause)
data class StreamInfo(
val bitrate: Int?,
val ssl: Boolean,
val contentType: String?
)
class StreamConnection(private val url: String) { class StreamConnection(private val url: String) {
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.socketFactory(LowLatencySocketFactory()) .socketFactory(LowLatencySocketFactory())
@@ -48,6 +54,8 @@ class StreamConnection(private val url: String) {
private set private set
var inputStream: InputStream? = null var inputStream: InputStream? = null
private set private set
var streamInfo: StreamInfo? = null
private set
private var response: Response? = null private var response: Response? = null
fun open() { fun open() {
@@ -65,6 +73,11 @@ class StreamConnection(private val url: String) {
} }
response = resp response = resp
metaint = resp.header("icy-metaint")?.toIntOrNull() metaint = resp.header("icy-metaint")?.toIntOrNull()
streamInfo = StreamInfo(
bitrate = resp.header("icy-br")?.toIntOrNull(),
ssl = url.startsWith("https", ignoreCase = true),
contentType = resp.header("Content-Type")
)
inputStream = resp.body?.byteStream() inputStream = resp.body?.byteStream()
?: throw ConnectionFailed("Empty response body") ?: throw ConnectionFailed("Empty response body")
} catch (e: IOException) { } catch (e: IOException) {
@@ -82,5 +95,6 @@ class StreamConnection(private val url: String) {
response = null response = null
inputStream = null inputStream = null
metaint = null metaint = null
streamInfo = null
} }
} }

View File

@@ -1,6 +1,7 @@
package xyz.cottongin.radio247.service package xyz.cottongin.radio247.service
import xyz.cottongin.radio247.audio.IcyMetadata import xyz.cottongin.radio247.audio.IcyMetadata
import xyz.cottongin.radio247.audio.StreamInfo
import xyz.cottongin.radio247.data.model.Station import xyz.cottongin.radio247.data.model.Station
sealed interface PlaybackState { sealed interface PlaybackState {
@@ -13,12 +14,14 @@ sealed interface PlaybackState {
val station: Station, val station: Station,
val metadata: IcyMetadata? = null, val metadata: IcyMetadata? = null,
val sessionStartedAt: Long = System.currentTimeMillis(), val sessionStartedAt: Long = System.currentTimeMillis(),
val connectionStartedAt: Long = System.currentTimeMillis() val connectionStartedAt: Long = System.currentTimeMillis(),
val streamInfo: StreamInfo? = null
) : PlaybackState ) : PlaybackState
data class Paused( data class Paused(
val station: Station, val station: Station,
val metadata: IcyMetadata? = null, val metadata: IcyMetadata? = null,
val sessionStartedAt: Long val sessionStartedAt: Long,
val streamInfo: StreamInfo? = null
) : PlaybackState ) : PlaybackState
data class Reconnecting( data class Reconnecting(
val station: Station, val station: Station,

View File

@@ -41,7 +41,8 @@ class RadioController(
_state.value = PlaybackState.Paused( _state.value = PlaybackState.Paused(
station = current.station, station = current.station,
metadata = current.metadata, metadata = current.metadata,
sessionStartedAt = current.sessionStartedAt sessionStartedAt = current.sessionStartedAt,
streamInfo = current.streamInfo
) )
} }
val intent = Intent(application, RadioPlaybackService::class.java).apply { val intent = Intent(application, RadioPlaybackService::class.java).apply {

View File

@@ -261,7 +261,8 @@ class RadioPlaybackService : LifecycleService() {
reconnectionMutex.withLock { reconnectionMutex.withLock {
engine?.stop() engine?.stop()
val bufferMs = app.preferences.bufferMs.first() val bufferMs = app.preferences.bufferMs.first()
engine = AudioEngine(station.url, bufferMs) val urls = app.streamResolver.resolveUrls(station)
engine = AudioEngine(urls.first(), bufferMs)
connectionSpanId = connectionSpanDao.insert( connectionSpanId = connectionSpanDao.insert(
ConnectionSpan( ConnectionSpan(
sessionId = listeningSessionId, sessionId = listeningSessionId,
@@ -297,6 +298,14 @@ class RadioPlaybackService : LifecycleService() {
updateNotification(station, event.metadata, false) updateNotification(station, event.metadata, false)
persistMetadataSnapshot(station.id, event.metadata) persistMetadataSnapshot(station.id, event.metadata)
} }
is AudioEngineEvent.StreamInfoReceived -> {
val playingState = controller.state.value
if (playingState is PlaybackState.Playing) {
controller.updateState(
playingState.copy(streamInfo = event.streamInfo)
)
}
}
is AudioEngineEvent.Started -> { is AudioEngineEvent.Started -> {
controller.updateLatency(engine!!.estimatedLatencyMs) controller.updateLatency(engine!!.estimatedLatencyMs)
} }
@@ -410,6 +419,7 @@ class RadioPlaybackService : LifecycleService() {
} }
private suspend fun persistMetadataSnapshot(stationId: Long, metadata: IcyMetadata) { private suspend fun persistMetadataSnapshot(stationId: Long, metadata: IcyMetadata) {
val now = System.currentTimeMillis()
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
metadataSnapshotDao.insert( metadataSnapshotDao.insert(
MetadataSnapshot( MetadataSnapshot(
@@ -417,9 +427,16 @@ class RadioPlaybackService : LifecycleService() {
title = metadata.title, title = metadata.title,
artist = metadata.artist, artist = metadata.artist,
artworkUrl = null, artworkUrl = null,
timestamp = System.currentTimeMillis() timestamp = now
) )
) )
val station = stationDao.getStationById(stationId)
app.historyWriter.append(
station = station?.name ?: "Unknown",
artist = metadata.artist,
title = metadata.title,
timestamp = now
)
} }
} }

View File

@@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import xyz.cottongin.radio247.RadioApplication import xyz.cottongin.radio247.RadioApplication
import xyz.cottongin.radio247.audio.IcyMetadata
import xyz.cottongin.radio247.service.PlaybackState import xyz.cottongin.radio247.service.PlaybackState
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -32,13 +33,17 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati
0 0
) )
private val _artworkUrl = MutableStateFlow<String?>(null) private val _displayArtworkUrl = MutableStateFlow<String?>(null)
val artworkUrl: StateFlow<String?> = _artworkUrl.asStateFlow() val displayArtworkUrl: StateFlow<String?> = _displayArtworkUrl.asStateFlow()
private val _displayMetadata = MutableStateFlow<IcyMetadata?>(null)
val displayMetadata: StateFlow<IcyMetadata?> = _displayMetadata.asStateFlow()
val sessionElapsed: StateFlow<Long> val sessionElapsed: StateFlow<Long>
val connectionElapsed: StateFlow<Long> val connectionElapsed: StateFlow<Long>
private var artworkResolveJob: Job? = null private var artworkResolveJob: Job? = null
private var displayStationId: Long? = null
init { init {
val ticker = flow { val ticker = flow {
@@ -75,32 +80,62 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati
PlaybackState.Idle -> null to null PlaybackState.Idle -> null to null
} }
when (state) { when (state) {
is PlaybackState.Connecting, is PlaybackState.Connecting -> {
artworkResolveJob?.cancel()
if (station?.id != displayStationId) {
displayStationId = station?.id
_displayMetadata.value = null
_displayArtworkUrl.value = station?.defaultArtworkUrl
}
}
is PlaybackState.Playing, is PlaybackState.Playing,
is PlaybackState.Paused, is PlaybackState.Paused,
is PlaybackState.Reconnecting -> { is PlaybackState.Reconnecting -> {
artworkResolveJob?.cancel() val stationChanged = station?.id != displayStationId
artworkResolveJob = viewModelScope.launch { val trackChanged = stationChanged || isTrackChange(metadata, _displayMetadata.value)
val url = station?.let { s ->
app.albumArtResolver.resolve( if (stationChanged) {
artist = metadata?.artist, displayStationId = station?.id
title = metadata?.title, }
icyStreamUrl = metadata?.streamUrl,
stationArtworkUrl = s.defaultArtworkUrl if (trackChanged && metadata != null) {
) artworkResolveJob?.cancel()
val pendingMetadata = metadata
artworkResolveJob = viewModelScope.launch {
val url = station?.let { s ->
app.albumArtResolver.resolve(
artist = pendingMetadata.artist,
title = pendingMetadata.title,
icyStreamUrl = pendingMetadata.streamUrl,
stationArtworkUrl = s.defaultArtworkUrl
)
}
_displayArtworkUrl.value = url
_displayMetadata.value = pendingMetadata
} }
_artworkUrl.value = url } else if (stationChanged) {
artworkResolveJob?.cancel()
_displayMetadata.value = null
_displayArtworkUrl.value = station?.defaultArtworkUrl
} }
} }
PlaybackState.Idle -> { PlaybackState.Idle -> {
artworkResolveJob?.cancel() artworkResolveJob?.cancel()
_artworkUrl.value = null displayStationId = null
_displayMetadata.value = null
_displayArtworkUrl.value = null
} }
} }
} }
} }
} }
private fun isTrackChange(new: IcyMetadata?, old: IcyMetadata?): Boolean {
if (new == null && old == null) return false
if (new == null || old == null) return true
return new.artist != old.artist || new.title != old.title
}
fun stop() { fun stop() {
controller.stop() controller.stop()
} }