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
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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.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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user