feat: add per-station quality UI — context menu and dialog

- Create QualityOverrideDialog with radio selection for preferred quality
- Add Quality menu item to station long-press (only for stations with streams)
- Add setQualityOverride, getStreamsForStation, getQualityOverrideForStation to ViewModel
- Add getStationIdsWithStreams to StationStreamDao for hasStreams lookup

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-11 16:22:59 -04:00
parent 92f3b35418
commit cf57d22dc5
4 changed files with 219 additions and 4 deletions

View File

@@ -3,6 +3,7 @@ package xyz.cottongin.radio247.data.db
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import xyz.cottongin.radio247.data.model.StationStream import xyz.cottongin.radio247.data.model.StationStream
@Dao @Dao
@@ -10,6 +11,9 @@ interface StationStreamDao {
@Query("SELECT * FROM station_streams WHERE stationId = :stationId") @Query("SELECT * FROM station_streams WHERE stationId = :stationId")
suspend fun getStreamsForStation(stationId: Long): List<StationStream> suspend fun getStreamsForStation(stationId: Long): List<StationStream>
@Query("SELECT DISTINCT stationId FROM station_streams")
fun getStationIdsWithStreams(): Flow<List<Long>>
@Insert @Insert
suspend fun insertAll(streams: List<StationStream>) suspend fun insertAll(streams: List<StationStream>)
} }

View File

@@ -0,0 +1,119 @@
package xyz.cottongin.radio247.ui.screens.stationlist
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.json.JSONArray
/**
* Formats a quality key for display: "256-ssl" → "256 kbps (SSL)", "128-nossl" → "128 kbps"
*/
fun formatQualityForDisplay(key: String): String {
val parts = key.split("-")
val kbps = parts.getOrNull(0) ?: key
val ssl = parts.getOrNull(1) == "ssl"
return if (ssl) "$kbps kbps (SSL)" else "$kbps kbps"
}
@Composable
fun QualityOverrideDialog(
currentOrder: List<String>,
availableQualities: List<String>,
onDismiss: () -> Unit,
onSave: (String?) -> Unit,
modifier: Modifier = Modifier
) {
var selectedOrder by remember(currentOrder, availableQualities) {
mutableStateOf(
if (currentOrder.isEmpty()) availableQualities
else currentOrder.filter { it in availableQualities } + availableQualities.filter { it !in currentOrder }
)
}
var useDefault by remember(currentOrder) {
mutableStateOf(currentOrder.isEmpty())
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Quality Preference") },
text = {
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Text(
text = "Select your preferred quality order (first = highest priority):",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp)
)
availableQualities.forEach { quality ->
val selectQuality: () -> Unit = {
useDefault = false
selectedOrder = listOf(quality) + availableQualities.filter { it != quality }
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = selectQuality)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = !useDefault && selectedOrder.firstOrNull() == quality,
onClick = selectQuality
)
Text(
text = formatQualityForDisplay(quality),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 8.dp)
)
}
}
TextButton(
onClick = { useDefault = true },
modifier = Modifier.padding(top = 8.dp)
) {
Text("Use Default")
}
}
},
confirmButton = {
TextButton(
onClick = {
if (useDefault) {
onSave(null)
} else {
onSave(JSONArray(selectedOrder).toString())
}
onDismiss()
}
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View File

@@ -62,7 +62,10 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import xyz.cottongin.radio247.data.model.Station import xyz.cottongin.radio247.data.model.Station
import xyz.cottongin.radio247.data.model.StationStream
import xyz.cottongin.radio247.service.PlaybackState import xyz.cottongin.radio247.service.PlaybackState
import xyz.cottongin.radio247.service.StreamResolver
import org.json.JSONArray
import xyz.cottongin.radio247.ui.components.MiniPlayer import xyz.cottongin.radio247.ui.components.MiniPlayer
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -83,6 +86,9 @@ fun StationListScreen(
var showAddStation by remember { mutableStateOf(false) } var showAddStation by remember { mutableStateOf(false) }
var showAddPlaylist by remember { mutableStateOf(false) } var showAddPlaylist by remember { mutableStateOf(false) }
var stationToEdit by remember { mutableStateOf<Station?>(null) } var stationToEdit by remember { mutableStateOf<Station?>(null) }
var stationForQuality by remember { mutableStateOf<Station?>(null) }
var qualityStreams by remember { mutableStateOf<List<StationStream>>(emptyList()) }
var qualityCurrentOrder by remember { mutableStateOf<List<String>>(emptyList()) }
val importLauncher = rememberLauncherForActivityResult( val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(), contract = ActivityResultContracts.OpenDocument(),
@@ -222,17 +228,37 @@ fun StationListScreen(
items = viewState.stations, items = viewState.stations,
key = { it.id } key = { it.id }
) { station -> ) { station ->
val hasStreams = station.id in viewState.stationIdsWithStreams
StationRow( StationRow(
station = station, station = station,
isNowPlaying = station.id == currentPlayingStationId, isNowPlaying = station.id == currentPlayingStationId,
showListeners = isBuiltInTab, showListeners = isBuiltInTab,
isBuiltIn = isBuiltInTab, isBuiltIn = isBuiltInTab,
isHiddenView = viewState.showHidden, isHiddenView = viewState.showHidden,
hasStreams = hasStreams,
onPlay = { viewModel.playStation(station) }, onPlay = { viewModel.playStation(station) },
onToggleStar = { viewModel.toggleStar(station) }, onToggleStar = { viewModel.toggleStar(station) },
onEdit = { stationToEdit = station }, onEdit = { stationToEdit = station },
onDelete = { viewModel.deleteStation(station) }, onDelete = { viewModel.deleteStation(station) },
onToggleHidden = { viewModel.toggleHidden(station) } onToggleHidden = { viewModel.toggleHidden(station) },
onQuality = {
viewModel.getStreamsForStation(station.id) { streams ->
viewModel.getQualityOverrideForStation(station.id) { json ->
qualityStreams = streams
qualityCurrentOrder = if (json != null) {
try {
val arr = JSONArray(json)
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) {
StreamResolver.DEFAULT_ORDER
}
} else {
emptyList()
}
stationForQuality = station
}
}
}
) )
} }
@@ -275,6 +301,27 @@ fun StationListScreen(
} }
) )
} }
stationForQuality?.let { station ->
val availableQualities = qualityStreams
.map { "${it.bitrate}-${if (it.ssl) "ssl" else "nossl"}" }
.distinct()
QualityOverrideDialog(
currentOrder = qualityCurrentOrder,
availableQualities = availableQualities,
onDismiss = {
stationForQuality = null
qualityStreams = emptyList()
qualityCurrentOrder = emptyList()
},
onSave = { qualityJson ->
viewModel.setQualityOverride(station.id, qualityJson)
stationForQuality = null
qualityStreams = emptyList()
qualityCurrentOrder = emptyList()
}
)
}
} }
@Composable @Composable
@@ -351,11 +398,13 @@ private fun StationRow(
showListeners: Boolean, showListeners: Boolean,
isBuiltIn: Boolean, isBuiltIn: Boolean,
isHiddenView: Boolean, isHiddenView: Boolean,
hasStreams: Boolean,
onPlay: () -> Unit, onPlay: () -> Unit,
onToggleStar: () -> Unit, onToggleStar: () -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onToggleHidden: () -> Unit, onToggleHidden: () -> Unit,
onQuality: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
@@ -433,6 +482,15 @@ private fun StationRow(
expanded = showMenu, expanded = showMenu,
onDismissRequest = { showMenu = false } onDismissRequest = { showMenu = false }
) { ) {
if (hasStreams && !isHiddenView) {
DropdownMenuItem(
text = { Text("Quality") },
onClick = {
showMenu = false
onQuality()
}
)
}
if (isHiddenView) { if (isHiddenView) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Unhide") }, text = { Text("Unhide") },

View File

@@ -10,6 +10,8 @@ import xyz.cottongin.radio247.data.importing.M3uParser
import xyz.cottongin.radio247.data.importing.PlsParser import xyz.cottongin.radio247.data.importing.PlsParser
import xyz.cottongin.radio247.data.model.Playlist import xyz.cottongin.radio247.data.model.Playlist
import xyz.cottongin.radio247.data.model.Station import xyz.cottongin.radio247.data.model.Station
import xyz.cottongin.radio247.data.model.StationPreference
import xyz.cottongin.radio247.data.model.StationStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -38,7 +40,8 @@ data class StationListViewState(
val stations: List<Station> = emptyList(), val stations: List<Station> = emptyList(),
val isPollingListeners: Boolean = false, val isPollingListeners: Boolean = false,
val hiddenCount: Int = 0, val hiddenCount: Int = 0,
val showHidden: Boolean = false val showHidden: Boolean = false,
val stationIdsWithStreams: Set<Long> = emptySet()
) )
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -87,9 +90,12 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
else stationDao.getHiddenCountByPlaylist(playlist.id) else stationDao.getHiddenCountByPlaylist(playlist.id)
} }
private val stationIdsWithStreamsFlow = app.database.stationStreamDao().getStationIdsWithStreams()
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val viewState: StateFlow<StationListViewState> = combine( val viewState: StateFlow<StationListViewState> = combine(
playlistsFlow, _selectedTabIndex, _sortMode, currentStationsFlow, playlistsFlow, _selectedTabIndex, _sortMode, currentStationsFlow,
_isPollingListeners, hiddenCountFlow, _showHidden _isPollingListeners, hiddenCountFlow, _showHidden, stationIdsWithStreamsFlow
) { values -> ) { values ->
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val playlists = values[0] as List<Playlist> val playlists = values[0] as List<Playlist>
@@ -100,6 +106,8 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
val isPolling = values[4] as Boolean val isPolling = values[4] as Boolean
val hiddenCount = values[5] as Int val hiddenCount = values[5] as Int
val showHidden = values[6] as Boolean val showHidden = values[6] as Boolean
@Suppress("UNCHECKED_CAST")
val stationIdsWithStreams = (values[7] as List<Long>).toSet()
val tabs = buildTabs(playlists) val tabs = buildTabs(playlists)
val safeIndex = tabIndex.coerceIn(0, (tabs.size - 1).coerceAtLeast(0)) val safeIndex = tabIndex.coerceIn(0, (tabs.size - 1).coerceAtLeast(0))
@@ -112,7 +120,8 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
stations = sorted, stations = sorted,
isPollingListeners = isPolling, isPollingListeners = isPolling,
hiddenCount = hiddenCount, hiddenCount = hiddenCount,
showHidden = showHidden showHidden = showHidden,
stationIdsWithStreams = stationIdsWithStreams
) )
}.stateIn(viewModelScope, SharingStarted.Lazily, StationListViewState()) }.stateIn(viewModelScope, SharingStarted.Lazily, StationListViewState())
@@ -244,6 +253,31 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
} }
} }
fun setQualityOverride(stationId: Long, qualityJson: String?) {
viewModelScope.launch {
val prefDao = app.database.stationPreferenceDao()
if (qualityJson == null) {
prefDao.deleteByStationId(stationId)
} else {
prefDao.upsert(StationPreference(stationId = stationId, qualityOverride = qualityJson))
}
}
}
fun getStreamsForStation(stationId: Long, callback: (List<StationStream>) -> Unit) {
viewModelScope.launch {
val streams = app.database.stationStreamDao().getStreamsForStation(stationId)
callback(streams)
}
}
fun getQualityOverrideForStation(stationId: Long, callback: (String?) -> Unit) {
viewModelScope.launch {
val pref = app.database.stationPreferenceDao().getByStationId(stationId)
callback(pref?.qualityOverride)
}
}
fun importFile(uri: Uri) { fun importFile(uri: Uri) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val content = app.contentResolver.openInputStream(uri)?.bufferedReader()?.readText() val content = app.contentResolver.openInputStream(uri)?.bufferedReader()?.readText()