opencode 55244b5027 fix: resolve 35 open issues across backend, Android, frontend, Docker
Backend (src/main.py):
- Add shared httpx.Client singleton for connection reuse (#24)
- Add cache eviction for expired entries (#23)
- Expand CORS to allow POST and OPTIONS (#25)
- Preserve HLS tags (EXT-X-TARGETDURATION, EXT-X-MAP, etc) in rewrite (#12)
- Replace urllib with httpx.stream for direct streaming

Backend (src/modules/stream_extractor.py):
- Use extract_flat='in_playlist' for reduced latency (#35)

Tests (tests/integration/test_api.py):
- Parameterize channel count assertion against CHANNELS (#20)

Android (Channel.kt):
- Change mutable vars to immutable vals, use copy() pattern (#19)

Android (ServerApi.kt):
- Fix JSON parsing for bare array response (#21)
- Close response body on error path (#29)

Android (YouTubeExtractor.kt):
- Add ConnectionPool config (10 idle, 30s keepalive) (#28)
- Tighten findHlsUrl to require audio codec (#26)

Android (AudioPlayer.kt):
- Remove false error on STATE_READY + !isPlaying (#33)

Android (MainActivity.kt):
- Reuse fragments via findFragmentByTag + show/hide (#18)
- Move AudioPlayer.release() to Activity.onDestroy (#17)

Android (LofiViewModel.kt):
- Wrap YouTubeExtractor calls in withContext(Dispatchers.IO) (#16)
- Use immutable channel copies in discoverAllChannels (#19)
- Remove audioPlayer.release() from onCleared (#17)

Android (ChannelListFragment.kt):
- Tie swipe refresh to isDiscovering LiveData (#32)

Android (AndroidManifest.xml):
- Set allowBackup=false (#15)

Android (Preferences.kt):
- Remove hardcoded IP, default to empty string (#14)

Android (proguard-rules.pro):
- Add ExoPlayer media3 HLS ProGuard rules (#34)

Frontend (useAudioPlayer.ts):
- Add retry limit (3) with exponential backoff for NETWORK_ERROR (#27)

Docker (Dockerfile.backend):
- Add playwright install chromium step (#22)
2026-07-05 12:33:26 +00:00

290 lines
9.7 KiB
Kotlin

package com.lofiradio.ui
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.lofiradio.data.CHANNELS
import com.lofiradio.data.models.Channel
import com.lofiradio.data.models.StreamInfo
import com.lofiradio.network.ServerApi
import com.lofiradio.network.YouTubeExtractor
import com.lofiradio.player.AudioPlayer
import com.lofiradio.util.Preferences
import kotlinx.coroutines.*
class LofiViewModel(
private val context: Context,
private val serverApi: ServerApi,
private val audioPlayer: AudioPlayer
) : ViewModel() {
private val _channelList = mutableListOf<Channel>()
private val _channels = androidx.lifecycle.MutableLiveData<List<Channel>>(emptyList())
val channels: androidx.lifecycle.LiveData<List<Channel>> get() = _channels
private fun notifyChannelsChanged() {
_channels.value = _channelList.toList()
}
private val _currentChannel = androidx.lifecycle.MutableLiveData<Channel?>(null)
val currentChannel: androidx.lifecycle.LiveData<Channel?> get() = _currentChannel
private val _playerState = androidx.lifecycle.MutableLiveData<PlayerState>(PlayerState.IDLE)
val playerState: androidx.lifecycle.LiveData<PlayerState> get() = _playerState
private val _isLoading = androidx.lifecycle.MutableLiveData<Boolean>(false)
val isLoading: androidx.lifecycle.LiveData<Boolean> get() = _isLoading
private val _error = androidx.lifecycle.MutableLiveData<String?>(null)
val error: androidx.lifecycle.LiveData<String?> get() = _error
private val _isDiscovering = androidx.lifecycle.MutableLiveData<Boolean>(false)
val isDiscovering: androidx.lifecycle.LiveData<Boolean> get() = _isDiscovering
private var currentTab: Tab = Tab.ALL
enum class Tab {
ALL,
FAVORITES,
LIVE
}
enum class PlayerState {
IDLE,
PLAYING,
PAUSED,
BUFFERING,
ERROR,
ENDED
}
init {
_channelList.addAll(CHANNELS.map { it.copy() })
notifyChannelsChanged()
audioPlayer.onStateChanged = { state ->
when (state) {
androidx.media3.common.Player.STATE_READY -> {
if (audioPlayer.isPlaying) {
_playerState.postValue(PlayerState.PLAYING)
} else {
_playerState.postValue(PlayerState.PAUSED)
}
}
androidx.media3.common.Player.STATE_BUFFERING -> {
_playerState.postValue(PlayerState.BUFFERING)
}
androidx.media3.common.Player.STATE_ENDED -> {
_playerState.postValue(PlayerState.ENDED)
if (Preferences.isAutoAdvance(context)) {
playNext()
}
}
androidx.media3.common.Player.STATE_IDLE -> {
_playerState.postValue(PlayerState.IDLE)
}
}
false
}
audioPlayer.onError = { message ->
_error.postValue(message)
_playerState.postValue(PlayerState.ERROR)
}
}
fun getVisibleChannels(): List<Channel> {
return when (currentTab) {
Tab.ALL -> _channelList
Tab.FAVORITES -> _channelList.filter { Preferences.isFavorite(context, it.id) }
Tab.LIVE -> _channelList.filter { it.isLive }
}
}
fun setTab(tab: Tab) {
currentTab = tab
}
fun toggleFavorite(channelId: String) {
Preferences.toggleFavorite(context, channelId)
val idx = _channelList.indexOfFirst { it.id == channelId }
if (idx != -1) {
val fav = Preferences.isFavorite(context, channelId)
_channelList[idx] = _channelList[idx].copy(isFavorite = fav)
notifyChannelsChanged()
}
}
fun playChannel(channel: Channel) {
_error.value = null
if (channel.videoId.isNullOrBlank()) {
_error.value = "Channel is not currently live"
_playerState.value = PlayerState.ERROR
return
}
when (Preferences.getStreamingMode(context)) {
Preferences.StreamingMode.SERVER -> {
playViaServer(channel)
}
Preferences.StreamingMode.DIRECT -> {
playViaDirect(channel)
}
}
}
private fun playViaServer(channel: Channel) {
_isLoading.value = true
viewModelScope.launch {
try {
val stream = serverApi.getStream(channel.videoId!!)
if (stream != null) {
audioPlayer.play(stream.videoId, stream.url, stream.streamType)
_currentChannel.value = channel
_playerState.value = PlayerState.PLAYING
} else {
_error.value = "Could not get stream from server"
_playerState.value = PlayerState.ERROR
}
} catch (e: Exception) {
Log.e("LofiViewModel", "Server play error", e)
_error.value = "Server connection failed: ${e.message}"
_playerState.value = PlayerState.ERROR
} finally {
_isLoading.value = false
}
}
}
private fun playViaDirect(channel: Channel) {
_isLoading.value = true
viewModelScope.launch {
try {
val stream = withContext(Dispatchers.IO) {
YouTubeExtractor.extractAudioStream(channel.videoId!!)
}
if (stream != null) {
audioPlayer.play(stream.videoId, stream.url, stream.streamType)
_currentChannel.value = channel
_playerState.value = PlayerState.PLAYING
} else {
_error.value = "Could not extract stream from YouTube"
_playerState.value = PlayerState.ERROR
}
} catch (e: Exception) {
Log.e("LofiViewModel", "Direct play error", e)
_error.value = "Extraction failed: ${e.message}"
_playerState.value = PlayerState.ERROR
} finally {
_isLoading.value = false
}
}
}
fun playNext() {
val current = _currentChannel.value ?: return
val visible = getVisibleChannels()
val currentIndex = visible.indexOfFirst { it.id == current.id }
val nextIndex = (currentIndex + 1) % visible.size
val nextChannel = visible[nextIndex]
if (nextChannel.isLive) {
playChannel(nextChannel)
}
}
fun playPrevious() {
val current = _currentChannel.value ?: return
val visible = getVisibleChannels()
val currentIndex = visible.indexOfFirst { it.id == current.id }
val prevIndex = if (currentIndex == 0) visible.size - 1 else currentIndex - 1
val prevChannel = visible[prevIndex]
if (prevChannel.isLive) {
playChannel(prevChannel)
}
}
fun pausePlayback() {
audioPlayer.pause()
}
fun resumePlayback() {
audioPlayer.resume()
}
fun stopPlayback() {
audioPlayer.stop()
_currentChannel.value = null
_playerState.value = PlayerState.IDLE
}
fun setVolume(volume: Float) {
audioPlayer.setVolume(volume)
}
fun retry() {
_error.value = null
val channel = _currentChannel.value
if (channel != null) {
playChannel(channel)
}
}
fun discoverAllChannels() {
_isDiscovering.value = true
viewModelScope.launch {
val mode = Preferences.getStreamingMode(context)
val apiKey = Preferences.getYouTubeApiKey(context)
val updated = mutableListOf<Channel>()
for (channel in _channelList.toList()) {
try {
val newChannel = when (mode) {
Preferences.StreamingMode.SERVER -> {
withContext(Dispatchers.IO) {
serverApi.checkChannelLive(channel.id)
}
}.let { live ->
channel.copy(
isLive = live?.isLive ?: false,
videoId = live?.videoId,
liveThumbnail = live?.thumbnail
)
}
Preferences.StreamingMode.DIRECT -> {
withContext(Dispatchers.IO) {
YouTubeExtractor.discoverVideo(channel.id, channel.handle, apiKey)
}
}.let { stream ->
channel.copy(
isLive = stream != null,
videoId = stream?.videoId
)
}
}
updated.add(newChannel)
} catch (e: Exception) {
Log.e("LofiViewModel", "Discovery error for ${channel.name}", e)
updated.add(channel)
}
delay(500)
}
_channelList.clear()
_channelList.addAll(updated)
notifyChannelsChanged()
_isDiscovering.value = false
}
}
fun getStreamingMode(): Preferences.StreamingMode {
return Preferences.getStreamingMode(context)
}
fun releaseAudioPlayer() {
audioPlayer.release()
}
override fun onCleared() {
}
}