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)
290 lines
9.7 KiB
Kotlin
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() {
|
|
}
|
|
}
|
|
|