feat: add Station List screen with playlists, starring, and import
Made-with: Cursor
This commit is contained in:
@@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Playlist>,
|
||||||
|
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<Long?>(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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Playlist>,
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,21 +1,357 @@
|
|||||||
package xyz.cottongin.radio247.ui.screens.stationlist
|
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.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.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
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.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
|
@Composable
|
||||||
fun StationListScreen(
|
fun StationListScreen(
|
||||||
onNavigateToNowPlaying: () -> Unit,
|
onNavigateToNowPlaying: () -> Unit,
|
||||||
onNavigateToSettings: () -> Unit,
|
onNavigateToSettings: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: StationListViewModel = viewModel(
|
||||||
|
factory = StationListViewModelFactory(LocalContext.current.applicationContext as xyz.cottongin.radio247.RadioApplication)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
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<Station?>(null) }
|
||||||
|
var expandedPlaylistIds by remember { mutableStateOf<Set<Long>>(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
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
// Placeholder - full implementation in Task 10
|
Text(
|
||||||
Column(modifier) {
|
text = title,
|
||||||
Text("Station List")
|
style = MaterialTheme.typography.titleSmall,
|
||||||
Button(onClick = onNavigateToNowPlaying) { Text("Now Playing") }
|
color = MaterialTheme.colorScheme.primary,
|
||||||
Button(onClick = onNavigateToSettings) { Text("Settings") }
|
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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Station>,
|
||||||
|
val playlistsWithStations: List<Pair<Playlist, List<Station>>>
|
||||||
|
)
|
||||||
|
|
||||||
|
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<Playlist>
|
||||||
|
val unsorted = array[1] as List<Station>
|
||||||
|
val stationLists = array.drop(2).map { it as List<Station> }
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
if (modelClass.isAssignableFrom(StationListViewModel::class.java)) {
|
||||||
|
return StationListViewModel(application) as T
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
|
}
|
||||||
|
}
|
||||||
29
chat-summaries/2026-03-09_brainstorm-and-plan-summary.md
Normal file
29
chat-summaries/2026-03-09_brainstorm-and-plan-summary.md
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user