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:
@@ -36,10 +36,12 @@ class AudioEngine(
|
||||
private var catchingUp = true
|
||||
@Volatile
|
||||
private var timedStream: TimedInputStream? = null
|
||||
private var presentationTimeUs = 0L
|
||||
|
||||
fun start() {
|
||||
running = true
|
||||
catchingUp = true
|
||||
presentationTimeUs = 0L
|
||||
thread = Thread({
|
||||
try {
|
||||
runPipeline()
|
||||
@@ -109,6 +111,7 @@ class AudioEngine(
|
||||
currentCodec = codec
|
||||
|
||||
_events.tryEmit(AudioEngineEvent.Started)
|
||||
connection.streamInfo?.let { _events.tryEmit(AudioEngineEvent.StreamInfoReceived(it)) }
|
||||
|
||||
try {
|
||||
val bufferFrames = if (bufferMs > 0) (bufferMs / 26).coerceAtLeast(1) else 0
|
||||
@@ -165,6 +168,7 @@ class AudioEngine(
|
||||
audioTrack.flush()
|
||||
audioTrack.play()
|
||||
drainCodecOutput(codec)
|
||||
presentationTimeUs = 0L
|
||||
return
|
||||
}
|
||||
|
||||
@@ -173,7 +177,8 @@ class AudioEngine(
|
||||
val inBuf = codec.getInputBuffer(inIdx)!!
|
||||
inBuf.clear()
|
||||
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()
|
||||
@@ -202,6 +207,7 @@ class AudioEngine(
|
||||
companion object {
|
||||
private const val FRAMES_PER_SECOND = 38
|
||||
private const val CATCHUP_THRESHOLD_MS = 30L
|
||||
private const val FRAME_DURATION_US = 26_122L // 1152 samples at 44100 Hz
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package xyz.cottongin.radio247.audio
|
||||
|
||||
sealed interface AudioEngineEvent {
|
||||
data class MetadataChanged(val metadata: IcyMetadata) : AudioEngineEvent
|
||||
data class StreamInfoReceived(val streamInfo: StreamInfo) : AudioEngineEvent
|
||||
data class Error(val cause: EngineError) : AudioEngineEvent
|
||||
data object Started : AudioEngineEvent
|
||||
data object Stopped : AudioEngineEvent
|
||||
|
||||
@@ -38,6 +38,12 @@ private class LowLatencySocketFactory : SocketFactory() {
|
||||
|
||||
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) {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.socketFactory(LowLatencySocketFactory())
|
||||
@@ -48,6 +54,8 @@ class StreamConnection(private val url: String) {
|
||||
private set
|
||||
var inputStream: InputStream? = null
|
||||
private set
|
||||
var streamInfo: StreamInfo? = null
|
||||
private set
|
||||
private var response: Response? = null
|
||||
|
||||
fun open() {
|
||||
@@ -65,6 +73,11 @@ class StreamConnection(private val url: String) {
|
||||
}
|
||||
response = resp
|
||||
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()
|
||||
?: throw ConnectionFailed("Empty response body")
|
||||
} catch (e: IOException) {
|
||||
@@ -82,5 +95,6 @@ class StreamConnection(private val url: String) {
|
||||
response = null
|
||||
inputStream = null
|
||||
metaint = null
|
||||
streamInfo = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package xyz.cottongin.radio247.service
|
||||
|
||||
import xyz.cottongin.radio247.audio.IcyMetadata
|
||||
import xyz.cottongin.radio247.audio.StreamInfo
|
||||
import xyz.cottongin.radio247.data.model.Station
|
||||
|
||||
sealed interface PlaybackState {
|
||||
@@ -13,12 +14,14 @@ sealed interface PlaybackState {
|
||||
val station: Station,
|
||||
val metadata: IcyMetadata? = null,
|
||||
val sessionStartedAt: Long = System.currentTimeMillis(),
|
||||
val connectionStartedAt: Long = System.currentTimeMillis()
|
||||
val connectionStartedAt: Long = System.currentTimeMillis(),
|
||||
val streamInfo: StreamInfo? = null
|
||||
) : PlaybackState
|
||||
data class Paused(
|
||||
val station: Station,
|
||||
val metadata: IcyMetadata? = null,
|
||||
val sessionStartedAt: Long
|
||||
val sessionStartedAt: Long,
|
||||
val streamInfo: StreamInfo? = null
|
||||
) : PlaybackState
|
||||
data class Reconnecting(
|
||||
val station: Station,
|
||||
|
||||
@@ -41,7 +41,8 @@ class RadioController(
|
||||
_state.value = PlaybackState.Paused(
|
||||
station = current.station,
|
||||
metadata = current.metadata,
|
||||
sessionStartedAt = current.sessionStartedAt
|
||||
sessionStartedAt = current.sessionStartedAt,
|
||||
streamInfo = current.streamInfo
|
||||
)
|
||||
}
|
||||
val intent = Intent(application, RadioPlaybackService::class.java).apply {
|
||||
|
||||
@@ -261,7 +261,8 @@ class RadioPlaybackService : LifecycleService() {
|
||||
reconnectionMutex.withLock {
|
||||
engine?.stop()
|
||||
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(
|
||||
ConnectionSpan(
|
||||
sessionId = listeningSessionId,
|
||||
@@ -297,6 +298,14 @@ class RadioPlaybackService : LifecycleService() {
|
||||
updateNotification(station, event.metadata, false)
|
||||
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 -> {
|
||||
controller.updateLatency(engine!!.estimatedLatencyMs)
|
||||
}
|
||||
@@ -410,6 +419,7 @@ class RadioPlaybackService : LifecycleService() {
|
||||
}
|
||||
|
||||
private suspend fun persistMetadataSnapshot(stationId: Long, metadata: IcyMetadata) {
|
||||
val now = System.currentTimeMillis()
|
||||
withContext(Dispatchers.IO) {
|
||||
metadataSnapshotDao.insert(
|
||||
MetadataSnapshot(
|
||||
@@ -417,9 +427,16 @@ class RadioPlaybackService : LifecycleService() {
|
||||
title = metadata.title,
|
||||
artist = metadata.artist,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import xyz.cottongin.radio247.RadioApplication
|
||||
import xyz.cottongin.radio247.audio.IcyMetadata
|
||||
import xyz.cottongin.radio247.service.PlaybackState
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -32,13 +33,17 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati
|
||||
0
|
||||
)
|
||||
|
||||
private val _artworkUrl = MutableStateFlow<String?>(null)
|
||||
val artworkUrl: StateFlow<String?> = _artworkUrl.asStateFlow()
|
||||
private val _displayArtworkUrl = MutableStateFlow<String?>(null)
|
||||
val displayArtworkUrl: StateFlow<String?> = _displayArtworkUrl.asStateFlow()
|
||||
|
||||
private val _displayMetadata = MutableStateFlow<IcyMetadata?>(null)
|
||||
val displayMetadata: StateFlow<IcyMetadata?> = _displayMetadata.asStateFlow()
|
||||
|
||||
val sessionElapsed: StateFlow<Long>
|
||||
val connectionElapsed: StateFlow<Long>
|
||||
|
||||
private var artworkResolveJob: Job? = null
|
||||
private var displayStationId: Long? = null
|
||||
|
||||
init {
|
||||
val ticker = flow {
|
||||
@@ -75,32 +80,62 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati
|
||||
PlaybackState.Idle -> null to null
|
||||
}
|
||||
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.Paused,
|
||||
is PlaybackState.Reconnecting -> {
|
||||
artworkResolveJob?.cancel()
|
||||
artworkResolveJob = viewModelScope.launch {
|
||||
val url = station?.let { s ->
|
||||
app.albumArtResolver.resolve(
|
||||
artist = metadata?.artist,
|
||||
title = metadata?.title,
|
||||
icyStreamUrl = metadata?.streamUrl,
|
||||
stationArtworkUrl = s.defaultArtworkUrl
|
||||
)
|
||||
val stationChanged = station?.id != displayStationId
|
||||
val trackChanged = stationChanged || isTrackChange(metadata, _displayMetadata.value)
|
||||
|
||||
if (stationChanged) {
|
||||
displayStationId = station?.id
|
||||
}
|
||||
|
||||
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 -> {
|
||||
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() {
|
||||
controller.stop()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user