feat: add Settings screen with export, history, and track search
Made-with: Cursor
This commit is contained in:
@@ -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,
|
||||
viewModel: SettingsViewModel = viewModel(
|
||||
factory = SettingsViewModelFactory(
|
||||
LocalContext.current.applicationContext as xyz.cottongin.radio247.RadioApplication
|
||||
)
|
||||
)
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
Column(modifier) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
Text("Settings")
|
||||
|
||||
@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user