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:
@@ -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>)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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") },
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user