feat: add Station List screen with playlists, starring, and import

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 02:42:25 -04:00
parent cacbb0d98d
commit 30b4bc9814
8 changed files with 842 additions and 7 deletions

View File

@@ -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"
)
}
}
}
}

View File

@@ -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")
}
}
)
}

View File

@@ -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")
}
}
)
}

View File

@@ -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")
}
}
)
}

View File

@@ -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()
}
)
}
} }
} }

View File

@@ -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
)
)
}
}
}
}

View File

@@ -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")
}
}

View 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