diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/settings/SettingsScreen.kt index 0f88261..accdc43 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/settings/SettingsScreen.kt @@ -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" } } diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/settings/SettingsViewModel.kt new file mode 100644 index 0000000..222b8ec --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/settings/SettingsViewModel.kt @@ -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> = + 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, + 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 + } +} diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/settings/SettingsViewModelFactory.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/settings/SettingsViewModelFactory.kt new file mode 100644 index 0000000..e3683a4 --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/settings/SettingsViewModelFactory.kt @@ -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 create(modelClass: Class): T { + if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) { + return SettingsViewModel(application) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +}