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