From cf57d22dc57a0ab51d16b2ced4e8e76ec81d57e5 Mon Sep 17 00:00:00 2001 From: cottongin Date: Wed, 11 Mar 2026 16:22:59 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20per-station=20quality=20UI=20?= =?UTF-8?q?=E2=80=94=20context=20menu=20and=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../radio247/data/db/StationStreamDao.kt | 4 + .../stationlist/QualityOverrideDialog.kt | 119 ++++++++++++++++++ .../screens/stationlist/StationListScreen.kt | 60 ++++++++- .../stationlist/StationListViewModel.kt | 40 +++++- 4 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/QualityOverrideDialog.kt diff --git a/app/src/main/java/xyz/cottongin/radio247/data/db/StationStreamDao.kt b/app/src/main/java/xyz/cottongin/radio247/data/db/StationStreamDao.kt index be43509..ef75508 100644 --- a/app/src/main/java/xyz/cottongin/radio247/data/db/StationStreamDao.kt +++ b/app/src/main/java/xyz/cottongin/radio247/data/db/StationStreamDao.kt @@ -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 + @Query("SELECT DISTINCT stationId FROM station_streams") + fun getStationIdsWithStreams(): Flow> + @Insert suspend fun insertAll(streams: List) } diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/QualityOverrideDialog.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/QualityOverrideDialog.kt new file mode 100644 index 0000000..f6f8fd9 --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/QualityOverrideDialog.kt @@ -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, + availableQualities: List, + 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") + } + } + ) +} diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt index 192746c..958beee 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt @@ -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(null) } + var stationForQuality by remember { mutableStateOf(null) } + var qualityStreams by remember { mutableStateOf>(emptyList()) } + var qualityCurrentOrder by remember { mutableStateOf>(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") }, diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt index e7fe213..e93c51c 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt @@ -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 = emptyList(), val isPollingListeners: Boolean = false, val hiddenCount: Int = 0, - val showHidden: Boolean = false + val showHidden: Boolean = false, + val stationIdsWithStreams: Set = 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 = combine( playlistsFlow, _selectedTabIndex, _sortMode, currentStationsFlow, - _isPollingListeners, hiddenCountFlow, _showHidden + _isPollingListeners, hiddenCountFlow, _showHidden, stationIdsWithStreamsFlow ) { values -> @Suppress("UNCHECKED_CAST") val playlists = values[0] as List @@ -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).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) -> 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()