feat: add Settings screen with export, history, and track search

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 02:49:38 -04:00
parent 7678b2b12a
commit 20daa86b52
3 changed files with 410 additions and 8 deletions

View File

@@ -1,23 +1,320 @@
package xyz.cottongin.radio247.ui.screens.settings
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import xyz.cottongin.radio247.data.model.ListeningSession
import xyz.cottongin.radio247.data.model.MetadataSnapshot
import xyz.cottongin.radio247.data.model.Station
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = viewModel(
factory = SettingsViewModelFactory(
LocalContext.current.applicationContext as xyz.cottongin.radio247.RadioApplication
)
)
) {
Column(modifier) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
val stayConnected by viewModel.stayConnected.collectAsState()
val bufferMs by viewModel.bufferMs.collectAsState()
val recentSessions by viewModel.recentSessions.collectAsState()
val filteredTracks by viewModel.filteredTracks.collectAsState()
val stations by viewModel.stations.collectAsState()
var trackHistoryQuery by remember { mutableStateOf("") }
var showExportDialog by remember { mutableStateOf(false) }
val createDocumentM3u = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("audio/x-mpegurl")
) { uri: Uri? ->
uri?.let {
viewModel.exportPlaylist(null, stations, "m3u", it)
}
Text("Settings")
}
val createDocumentPls = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("audio/x-scpls")
) { uri: Uri? ->
uri?.let {
viewModel.exportPlaylist(null, stations, "pls", it)
}
}
Column(modifier = modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
)
)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
SectionHeader("PLAYBACK")
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "Stay Connected")
Switch(
checked = stayConnected,
onCheckedChange = { viewModel.setStayConnected(it) }
)
}
Text(
text = "Buffer: ${bufferMs}ms",
style = MaterialTheme.typography.bodySmall
)
Slider(
value = bufferMs.toFloat(),
onValueChange = { viewModel.setBufferMs(it.toInt()) },
valueRange = 0f..500f,
steps = 49,
modifier = Modifier.fillMaxWidth(),
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary
)
)
Spacer(modifier = Modifier.height(24.dp))
SectionHeader("EXPORT")
Button(
onClick = { showExportDialog = true },
modifier = Modifier.fillMaxWidth()
) {
Text("Export Playlist")
}
Spacer(modifier = Modifier.height(24.dp))
SectionHeader("RECENTLY PLAYED")
val stationMap = stations.associateBy { it.id }
LazyColumn(
modifier = Modifier.heightIn(max = 200.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(recentSessions, key = { it.id }) { session ->
RecentSessionRow(
session = session,
stationName = stationMap[session.stationId]?.name ?: "Unknown"
)
}
}
Spacer(modifier = Modifier.height(24.dp))
SectionHeader("TRACK HISTORY")
OutlinedTextField(
value = trackHistoryQuery,
onValueChange = {
trackHistoryQuery = it
viewModel.setTrackHistoryQuery(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Search...") },
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
modifier = Modifier.heightIn(max = 200.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filteredTracks, key = { it.id }) { snapshot ->
TrackHistoryRow(
snapshot = snapshot,
stationName = stationMap[snapshot.stationId]?.name ?: "Unknown"
)
}
}
}
}
if (showExportDialog) {
AlertDialog(
onDismissRequest = { showExportDialog = false },
confirmButton = {
Button(onClick = { showExportDialog = false }) {
Text("Cancel")
}
},
title = { Text("Export format") },
text = {
Column {
Button(
onClick = {
showExportDialog = false
createDocumentM3u.launch("playlist.m3u")
},
modifier = Modifier.fillMaxWidth()
) {
Text("M3U")
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
showExportDialog = false
createDocumentPls.launch("playlist.pls")
},
modifier = Modifier.fillMaxWidth()
) {
Text("PLS")
}
}
}
)
}
}
@Composable
private fun SectionHeader(
title: String,
modifier: Modifier = Modifier
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = modifier.padding(vertical = 8.dp)
)
}
@Composable
private fun RecentSessionRow(
session: ListeningSession,
stationName: String,
modifier: Modifier = Modifier
) {
val duration = if (session.endedAt != null) {
formatDuration(session.endedAt - session.startedAt)
} else {
"In progress"
}
val relativeTime = formatRelativeTime(session.startedAt)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stationName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Text(
text = "$relativeTime | $duration",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun TrackHistoryRow(
snapshot: MetadataSnapshot,
stationName: String,
modifier: Modifier = Modifier
) {
val trackInfo = when {
snapshot.artist != null && snapshot.title != null ->
"${snapshot.artist} - ${snapshot.title}"
snapshot.title != null -> snapshot.title
snapshot.artist != null -> snapshot.artist
else -> "Unknown track"
}
val timestamp = formatRelativeTime(snapshot.timestamp)
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = trackInfo,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "$stationName | $timestamp",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
private fun formatDuration(millis: Long): String {
if (millis <= 0) return "0s"
val totalSeconds = millis / 1000
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return buildString {
if (hours > 0) append("${hours}h ")
if (minutes > 0 || hours > 0) append("${minutes}m ")
append("${seconds}s")
}.trim()
}
private fun formatRelativeTime(timestampMs: Long): String {
val now = System.currentTimeMillis()
val diff = now - timestampMs
return when {
diff < 60_000 -> "${diff / 1000}s ago"
diff < 3600_000 -> "${diff / 60_000}m ago"
diff < 86400_000 -> "${diff / 3600_000}h ago"
else -> "${diff / 86400_000}d ago"
}
}

View File

@@ -0,0 +1,87 @@
package xyz.cottongin.radio247.ui.screens.settings
import android.app.Application
import kotlinx.coroutines.ExperimentalCoroutinesApi
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import xyz.cottongin.radio247.RadioApplication
import xyz.cottongin.radio247.data.importing.PlaylistExporter
import xyz.cottongin.radio247.data.model.MetadataSnapshot
import xyz.cottongin.radio247.data.model.Station
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
private val app = application as RadioApplication
val stayConnected = app.preferences.stayConnected.stateIn(
viewModelScope,
SharingStarted.Lazily,
false
)
val bufferMs = app.preferences.bufferMs.stateIn(
viewModelScope,
SharingStarted.Lazily,
0
)
val recentSessions = app.database.listeningSessionDao()
.getRecentSessions(50)
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val trackHistory = MutableStateFlow("")
val filteredTracks: StateFlow<List<MetadataSnapshot>> =
trackHistory.flatMapLatest { query ->
if (query.isBlank()) {
app.database.metadataSnapshotDao().getRecent(100)
} else {
app.database.metadataSnapshotDao().search(query)
}
}.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val stations = app.database.stationDao()
.getAllStations()
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
fun setStayConnected(value: Boolean) {
viewModelScope.launch {
app.preferences.setStayConnected(value)
}
}
fun setBufferMs(value: Int) {
viewModelScope.launch {
app.preferences.setBufferMs(value)
}
}
fun exportPlaylist(
playlistId: Long?,
stations: List<Station>,
format: String,
uri: Uri
) {
viewModelScope.launch(Dispatchers.IO) {
val content = when (format) {
"m3u" -> PlaylistExporter.toM3u(stations)
"pls" -> PlaylistExporter.toPls(stations)
else -> return@launch
}
app.contentResolver.openOutputStream(uri)?.use {
it.write(content.toByteArray())
}
}
}
fun setTrackHistoryQuery(query: String) {
trackHistory.value = query
}
}

View File

@@ -0,0 +1,18 @@
package xyz.cottongin.radio247.ui.screens.settings
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import xyz.cottongin.radio247.RadioApplication
class SettingsViewModelFactory(
private val application: RadioApplication
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) {
return SettingsViewModel(application) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}