diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/components/MiniPlayer.kt b/app/src/main/java/xyz/cottongin/radio247/ui/components/MiniPlayer.kt new file mode 100644 index 0000000..acaeecb --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/ui/components/MiniPlayer.kt @@ -0,0 +1,74 @@ +package xyz.cottongin.radio247.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import xyz.cottongin.radio247.audio.IcyMetadata +import xyz.cottongin.radio247.data.model.Station +import xyz.cottongin.radio247.service.PlaybackState + +@Composable +fun MiniPlayer( + state: PlaybackState, + onTap: () -> Unit, + onStop: () -> Unit, + modifier: Modifier = Modifier +) { + val (station, metadata) = when (state) { + is PlaybackState.Playing -> state.station to state.metadata + is PlaybackState.Reconnecting -> state.station to state.metadata + PlaybackState.Idle -> return + } + + Surface( + modifier = modifier.fillMaxWidth(), + tonalElevation = 2.dp + ) { + Row( + modifier = Modifier + .clickable(onClick = onTap) + .padding(horizontal = 16.dp, vertical = 12.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = station.name, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f) + ) + metadata?.title?.let { title -> + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodySmall, + maxLines = 1 + ) + } + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + onClick = onStop, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.Default.Stop, + contentDescription = "Stop" + ) + } + } + } +} diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/AddPlaylistDialog.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/AddPlaylistDialog.kt new file mode 100644 index 0000000..18b528e --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/AddPlaylistDialog.kt @@ -0,0 +1,53 @@ +package xyz.cottongin.radio247.ui.screens.stationlist + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.ui.Modifier +import androidx.compose.material3.OutlinedTextField +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 + +@Composable +fun AddPlaylistDialog( + onDismiss: () -> Unit, + onConfirm: (name: String) -> Unit, + modifier: Modifier = Modifier +) { + var name by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Playlist") }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + }, + confirmButton = { + TextButton( + onClick = { + if (name.isNotBlank()) { + onConfirm(name.trim()) + onDismiss() + } + } + ) { + Text("Add") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/AddStationDialog.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/AddStationDialog.kt new file mode 100644 index 0000000..116805a --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/AddStationDialog.kt @@ -0,0 +1,107 @@ +package xyz.cottongin.radio247.ui.screens.stationlist + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.OutlinedTextField +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.Modifier +import androidx.compose.ui.unit.dp +import xyz.cottongin.radio247.data.model.Playlist + +@Composable +fun AddStationDialog( + playlists: List, + onDismiss: () -> Unit, + onConfirm: (name: String, url: String, playlistId: Long?) -> Unit, + modifier: Modifier = Modifier +) { + var name by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + var selectedPlaylistId by remember { mutableStateOf(null) } + var playlistMenuExpanded by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Station") }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text("URL") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(8.dp)) + Box(modifier = Modifier.fillMaxWidth().clickable { playlistMenuExpanded = true }) { + OutlinedTextField( + value = playlists.find { it.id == selectedPlaylistId }?.name ?: "No playlist", + onValueChange = {}, + readOnly = true, + label = { Text("Playlist") }, + modifier = Modifier.fillMaxWidth() + ) + DropdownMenu( + expanded = playlistMenuExpanded, + onDismissRequest = { playlistMenuExpanded = false } + ) { + DropdownMenuItem( + text = { Text("No playlist") }, + onClick = { + selectedPlaylistId = null + playlistMenuExpanded = false + } + ) + for (playlist in playlists) { + DropdownMenuItem( + text = { Text(playlist.name) }, + onClick = { + selectedPlaylistId = playlist.id + playlistMenuExpanded = false + } + ) + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + if (name.isNotBlank() && url.isNotBlank()) { + onConfirm(name.trim(), url.trim(), selectedPlaylistId) + onDismiss() + } + } + ) { + Text("Add") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/EditStationDialog.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/EditStationDialog.kt new file mode 100644 index 0000000..2668595 --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/EditStationDialog.kt @@ -0,0 +1,109 @@ +package xyz.cottongin.radio247.ui.screens.stationlist + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.OutlinedTextField +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.Modifier +import androidx.compose.ui.unit.dp +import xyz.cottongin.radio247.data.model.Playlist +import xyz.cottongin.radio247.data.model.Station + +@Composable +fun EditStationDialog( + station: Station, + playlists: List, + onDismiss: () -> Unit, + onConfirm: (name: String, url: String, playlistId: Long?) -> Unit, + modifier: Modifier = Modifier +) { + var name by remember(station.id) { mutableStateOf(station.name) } + var url by remember(station.id) { mutableStateOf(station.url) } + var selectedPlaylistId by remember(station.id) { mutableStateOf(station.playlistId) } + var playlistMenuExpanded by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Edit Station") }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text("URL") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(8.dp)) + Box(modifier = Modifier.fillMaxWidth().clickable { playlistMenuExpanded = true }) { + OutlinedTextField( + value = playlists.find { it.id == selectedPlaylistId }?.name ?: "No playlist", + onValueChange = {}, + readOnly = true, + label = { Text("Playlist") }, + modifier = Modifier.fillMaxWidth() + ) + DropdownMenu( + expanded = playlistMenuExpanded, + onDismissRequest = { playlistMenuExpanded = false } + ) { + DropdownMenuItem( + text = { Text("No playlist") }, + onClick = { + selectedPlaylistId = null + playlistMenuExpanded = false + } + ) + for (playlist in playlists) { + DropdownMenuItem( + text = { Text(playlist.name) }, + onClick = { + selectedPlaylistId = playlist.id + playlistMenuExpanded = false + } + ) + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + if (name.isNotBlank() && url.isNotBlank()) { + onConfirm(name.trim(), url.trim(), selectedPlaylistId) + onDismiss() + } + } + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt index 850c0ce..fdd77c7 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt @@ -1,21 +1,357 @@ package xyz.cottongin.radio247.ui.screens.stationlist +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.material3.Button +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.Star +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold 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.unit.dp +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel +import xyz.cottongin.radio247.data.model.Playlist +import xyz.cottongin.radio247.data.model.Station +import xyz.cottongin.radio247.service.PlaybackState +import xyz.cottongin.radio247.ui.components.MiniPlayer +@OptIn(ExperimentalMaterial3Api::class) @Composable fun StationListScreen( onNavigateToNowPlaying: () -> Unit, onNavigateToSettings: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + viewModel: StationListViewModel = viewModel( + factory = StationListViewModelFactory(LocalContext.current.applicationContext as xyz.cottongin.radio247.RadioApplication) + ) ) { - // Placeholder - full implementation in Task 10 - Column(modifier) { - Text("Station List") - Button(onClick = onNavigateToNowPlaying) { Text("Now Playing") } - Button(onClick = onNavigateToSettings) { Text("Settings") } + val viewState by viewModel.viewState.collectAsState() + val playbackState by viewModel.playbackState.collectAsState() + val playlists = viewState.playlistsWithStations.map { it.first } + + var showAddStation by remember { mutableStateOf(false) } + var showAddPlaylist by remember { mutableStateOf(false) } + var stationToEdit by remember { mutableStateOf(null) } + var expandedPlaylistIds by remember { mutableStateOf>(emptySet()) } + + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri: Uri? -> + uri?.let { viewModel.importFile(it) } + } + ) + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text("24/7 Radio") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface + ), + actions = { + IconButton(onClick = { + importLauncher.launch( + arrayOf( + "audio/*", + "application/x-mpegurl", + "*/*" + ) + ) + }) { + Icon(Icons.Default.Upload, contentDescription = "Import") + } + IconButton(onClick = { showAddStation = true }) { + Icon(Icons.Default.Add, contentDescription = "Add Station") + } + IconButton(onClick = { showAddPlaylist = true }) { + Icon(Icons.Default.Folder, contentDescription = "Add Playlist") + } + IconButton(onClick = onNavigateToSettings) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } + } + ) + }, + bottomBar = { + when (playbackState) { + is PlaybackState.Playing, + is PlaybackState.Reconnecting -> MiniPlayer( + state = playbackState, + onTap = onNavigateToNowPlaying, + onStop = { viewModel.controller.stop() } + ) + PlaybackState.Idle -> {} + } + } + ) { paddingValues -> + val currentPlayingStationId = when (val state = playbackState) { + is PlaybackState.Playing -> state.station.id + is PlaybackState.Reconnecting -> state.station.id + PlaybackState.Idle -> null + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp) + ) { + if (viewState.unsortedStations.isNotEmpty()) { + item(key = "unsorted_header") { + SectionHeader("Unsorted") + } + items( + items = viewState.unsortedStations, + key = { it.id } + ) { station -> + StationRow( + station = station, + isNowPlaying = station.id == currentPlayingStationId, + onPlay = { viewModel.playStation(station) }, + onToggleStar = { viewModel.toggleStar(station) }, + onEdit = { stationToEdit = station }, + onDelete = { viewModel.deleteStation(station) } + ) + } + } + + for ((playlist, stations) in viewState.playlistsWithStations) { + val isExpanded = playlist.id in expandedPlaylistIds + item(key = "playlist_header_${playlist.id}") { + PlaylistSectionHeader( + playlist = playlist, + stationCount = stations.size, + isExpanded = isExpanded, + onToggleExpand = { + expandedPlaylistIds = if (isExpanded) { + expandedPlaylistIds - playlist.id + } else { + expandedPlaylistIds + playlist.id + } + }, + onToggleStar = { viewModel.togglePlaylistStar(playlist) } + ) + } + if (isExpanded) { + items( + items = stations, + key = { it.id } + ) { station -> + StationRow( + station = station, + isNowPlaying = station.id == currentPlayingStationId, + onPlay = { viewModel.playStation(station) }, + onToggleStar = { viewModel.toggleStar(station) }, + onEdit = { stationToEdit = station }, + onDelete = { viewModel.deleteStation(station) } + ) + } + } + } + } + } + + if (showAddStation) { + AddStationDialog( + playlists = playlists, + onDismiss = { showAddStation = false }, + onConfirm = { name, url, playlistId -> + viewModel.addStation(name, url, playlistId) + showAddStation = false + } + ) + } + + if (showAddPlaylist) { + AddPlaylistDialog( + onDismiss = { showAddPlaylist = false }, + onConfirm = { name -> + viewModel.addPlaylist(name) + showAddPlaylist = false + } + ) + } + + stationToEdit?.let { station -> + EditStationDialog( + station = station, + playlists = playlists, + onDismiss = { stationToEdit = null }, + onConfirm = { name, url, playlistId -> + viewModel.updateStation(station, name, url, playlistId) + stationToEdit = null + } + ) + } +} + +@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 PlaylistSectionHeader( + playlist: Playlist, + stationCount: Int, + isExpanded: Boolean, + onToggleExpand: () -> Unit, + onToggleStar: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onToggleExpand) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (isExpanded) Icons.Default.Folder else Icons.Default.Folder, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${playlist.name} ($stationCount)", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { onToggleStar() }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = if (playlist.starred) Icons.Default.Star else Icons.Outlined.Star, + contentDescription = if (playlist.starred) "Unstar" else "Star" + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun StationRow( + station: Station, + isNowPlaying: Boolean, + onPlay: () -> Unit, + onToggleStar: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier +) { + var showMenu by remember { mutableStateOf(false) } + + Box(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = onPlay, + onLongClick = { showMenu = true } + ), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { onToggleStar() }, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = if (station.starred) Icons.Default.Star else Icons.Outlined.Star, + contentDescription = if (station.starred) "Unstar" else "Star" + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = station.name, + style = MaterialTheme.typography.bodyLarge + ) + if (isNowPlaying) { + Text( + text = "Now playing", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + if (isNowPlaying) { + Icon( + Icons.Default.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text("Edit") }, + onClick = { + showMenu = false + onEdit() + } + ) + DropdownMenuItem( + text = { Text("Delete") }, + onClick = { + showMenu = false + onDelete() + } + ) + } } } diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt new file mode 100644 index 0000000..3978228 --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt @@ -0,0 +1,109 @@ +package xyz.cottongin.radio247.ui.screens.stationlist + +import android.app.Application +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import xyz.cottongin.radio247.RadioApplication +import xyz.cottongin.radio247.data.db.PlaylistDao +import xyz.cottongin.radio247.data.db.StationDao +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class StationListViewState( + val unsortedStations: List, + val playlistsWithStations: List>> +) + +class StationListViewModel(application: Application) : AndroidViewModel(application) { + private val app = application as RadioApplication + private val stationDao = app.database.stationDao() + private val playlistDao = app.database.playlistDao() + val controller = app.controller + + val playbackState = controller.state + + val viewState = playlistDao.getAllPlaylists().flatMapLatest { playlists -> + val stationFlows = playlists.map { stationDao.getStationsByPlaylist(it.id) } + combine( + flowOf(playlists), + stationDao.getUnsortedStations(), + *stationFlows.toTypedArray() + ) { array -> + val pl = array[0] as List + val unsorted = array[1] as List + val stationLists = array.drop(2).map { it as List } + StationListViewState( + unsortedStations = unsorted, + playlistsWithStations = pl.zip(stationLists) { p, s -> p to s } + ) + } + }.stateIn(viewModelScope, SharingStarted.Lazily, StationListViewState(emptyList(), emptyList())) + + fun playStation(station: Station) { + viewModelScope.launch { + app.preferences.setLastStationId(station.id) + controller.play(station) + } + } + + fun toggleStar(station: Station) { + viewModelScope.launch { stationDao.toggleStarred(station.id, !station.starred) } + } + + fun togglePlaylistStar(playlist: Playlist) { + viewModelScope.launch { playlistDao.toggleStarred(playlist.id, !playlist.starred) } + } + + fun deleteStation(station: Station) { + viewModelScope.launch { stationDao.delete(station) } + } + + fun addStation(name: String, url: String, playlistId: Long?) { + viewModelScope.launch { + stationDao.insert(Station(name = name, url = url, playlistId = playlistId)) + } + } + + fun updateStation(station: Station, name: String, url: String, playlistId: Long?) { + viewModelScope.launch { + stationDao.update( + station.copy(name = name, url = url, playlistId = playlistId) + ) + } + } + + fun addPlaylist(name: String) { + viewModelScope.launch { + playlistDao.insert(Playlist(name = name)) + } + } + + fun importFile(uri: Uri) { + viewModelScope.launch(Dispatchers.IO) { + val content = app.contentResolver.openInputStream(uri)?.bufferedReader()?.readText() + ?: return@launch + val isM3u = content.trimStart().startsWith("#EXTM3U") || + uri.toString().endsWith(".m3u", ignoreCase = true) + val parsed = if (isM3u) M3uParser.parse(content) else PlsParser.parse(content) + for (station in parsed) { + stationDao.insert( + Station( + name = station.name, + url = station.url, + defaultArtworkUrl = station.artworkUrl + ) + ) + } + } + } +} diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModelFactory.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModelFactory.kt new file mode 100644 index 0000000..68750d3 --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModelFactory.kt @@ -0,0 +1,18 @@ +package xyz.cottongin.radio247.ui.screens.stationlist + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import xyz.cottongin.radio247.RadioApplication + +class StationListViewModelFactory( + private val application: RadioApplication +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(StationListViewModel::class.java)) { + return StationListViewModel(application) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/chat-summaries/2026-03-09_brainstorm-and-plan-summary.md b/chat-summaries/2026-03-09_brainstorm-and-plan-summary.md new file mode 100644 index 0000000..8bc694c --- /dev/null +++ b/chat-summaries/2026-03-09_brainstorm-and-plan-summary.md @@ -0,0 +1,29 @@ +# Brainstorm & Implementation Plan — Android 24/7 Radio + +**Date:** 2026-03-09 + +## Task Description + +Brainstormed and designed a personal-use Android radio streaming app from an IDEA.md spec. Produced a full design document and a 15-task implementation plan. + +## Key Decisions + +- **Custom raw audio pipeline** (Approach B) over ExoPlayer, for absolute minimum latency (~26ms per MP3 frame vs ExoPlayer's ~1-2s floor) +- **Pipeline:** OkHttp → IcyParser → Mp3FrameSync → MediaCodec → AudioTrack, single-threaded +- **Kotlin + Jetpack Compose + Material 3**, targeting API 28 (Android 9) minimum +- **Room DB** with future-proofed schema (MetadataSnapshot, ListeningSession, ConnectionSpan tables for future recording/clips) +- **PLS/M3U import/export** with `#EXTIMG` support for station default artwork +- **Album art fallback chain:** MusicBrainz → ICY StreamUrl → EXTIMG → Station favicon → Placeholder +- **Dual timers** on Now Playing: session time (never resets) + connection time (resets on reconnect) +- **Latency indicator** estimated from ring buffer + AudioTrack write/play head delta + +## Changes Made + +- `docs/plans/2026-03-09-android-247-radio-design.md` — Full design document (6 sections) +- `docs/plans/2026-03-09-android-247-radio-implementation.md` — 15-task implementation plan with TDD steps +- Initialized git repository with 2 commits + +## Follow-Up Items + +- Execute the implementation plan (15 tasks, starting with project scaffolding) +- Execution options: subagent-driven (this session) or parallel session with executing-plans skill