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.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import xyz.cottongin.radio247.data.model.StationStream
@Dao
@@ -10,6 +11,9 @@ interface StationStreamDao {
@Query("SELECT * FROM station_streams WHERE stationId = :stationId")
suspend fun getStreamsForStation(stationId: Long): List<StationStream>
@Query("SELECT DISTINCT stationId FROM station_streams")
fun getStationIdsWithStreams(): Flow<List<Long>>
@Insert
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 coil3.compose.AsyncImage
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.StreamResolver
import org.json.JSONArray
import xyz.cottongin.radio247.ui.components.MiniPlayer
@OptIn(ExperimentalMaterial3Api::class)
@@ -83,6 +86,9 @@ fun StationListScreen(
var showAddStation by remember { mutableStateOf(false) }
var showAddPlaylist by remember { mutableStateOf(false) }
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(
contract = ActivityResultContracts.OpenDocument(),
@@ -222,17 +228,37 @@ fun StationListScreen(
items = viewState.stations,
key = { it.id }
) { station ->
val hasStreams = station.id in viewState.stationIdsWithStreams
StationRow(
station = station,
isNowPlaying = station.id == currentPlayingStationId,
showListeners = isBuiltInTab,
isBuiltIn = isBuiltInTab,
isHiddenView = viewState.showHidden,
hasStreams = hasStreams,
onPlay = { viewModel.playStation(station) },
onToggleStar = { viewModel.toggleStar(station) },
onEdit = { stationToEdit = 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
@@ -351,11 +398,13 @@ private fun StationRow(
showListeners: Boolean,
isBuiltIn: Boolean,
isHiddenView: Boolean,
hasStreams: Boolean,
onPlay: () -> Unit,
onToggleStar: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
onToggleHidden: () -> Unit,
onQuality: () -> Unit,
modifier: Modifier = Modifier
) {
var showMenu by remember { mutableStateOf(false) }
@@ -433,6 +482,15 @@ private fun StationRow(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
if (hasStreams && !isHiddenView) {
DropdownMenuItem(
text = { Text("Quality") },
onClick = {
showMenu = false
onQuality()
}
)
}
if (isHiddenView) {
DropdownMenuItem(
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.model.Playlist
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.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
@@ -38,7 +40,8 @@ data class StationListViewState(
val stations: List<Station> = emptyList(),
val isPollingListeners: Boolean = false,
val hiddenCount: Int = 0,
val showHidden: Boolean = false
val showHidden: Boolean = false,
val stationIdsWithStreams: Set<Long> = emptySet()
)
@OptIn(ExperimentalCoroutinesApi::class)
@@ -87,9 +90,12 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
else stationDao.getHiddenCountByPlaylist(playlist.id)
}
private val stationIdsWithStreamsFlow = app.database.stationStreamDao().getStationIdsWithStreams()
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val viewState: StateFlow<StationListViewState> = combine(
playlistsFlow, _selectedTabIndex, _sortMode, currentStationsFlow,
_isPollingListeners, hiddenCountFlow, _showHidden
_isPollingListeners, hiddenCountFlow, _showHidden, stationIdsWithStreamsFlow
) { values ->
@Suppress("UNCHECKED_CAST")
val playlists = values[0] as List<Playlist>
@@ -100,6 +106,8 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
val isPolling = values[4] as Boolean
val hiddenCount = values[5] as Int
val showHidden = values[6] as Boolean
@Suppress("UNCHECKED_CAST")
val stationIdsWithStreams = (values[7] as List<Long>).toSet()
val tabs = buildTabs(playlists)
val safeIndex = tabIndex.coerceIn(0, (tabs.size - 1).coerceAtLeast(0))
@@ -112,7 +120,8 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
stations = sorted,
isPollingListeners = isPolling,
hiddenCount = hiddenCount,
showHidden = showHidden
showHidden = showHidden,
stationIdsWithStreams = stationIdsWithStreams
)
}.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) {
viewModelScope.launch(Dispatchers.IO) {
val content = app.contentResolver.openInputStream(uri)?.bufferedReader()?.readText()