feat: directory search, favorites, motion system, home button, transport fix

- radio-browser.info directory search (debounced, ~58k stations) with
  codec/bitrate/country metadata and save-to-stations action
- station favorites (star) persisted in StationRepository; Starred
  section pinned in Stations; active-station row highlight
- motion system (Motion.kt): shared-axis nav transitions, hub hop,
  metadata rise, play/pause pop; LocalReduceMotion honors system
  'remove animations'
- AppHomeButton (launcher icon) top-left on every surface + SatsAmp
  wordmark on the hub; home pops the stack back to the player
- transport row: add missing Next button, conventional order
  stop · prev · play · next
- WaveGraphic center glow: wider, fainter, no perceptible disc edge
- StreamMetadata: strip unsubstituted {{template}} residue
- PRODUCT.md + DESIGN.md (impeccable design context); .gitignore for
  re-downloadable skill engine binaries
This commit is contained in:
avi 2026-09-08 00:37:33 -05:00
commit bc6396a570
20 changed files with 1068 additions and 73 deletions

View file

@ -0,0 +1,88 @@
package com.rada.data.repository
import com.rada.domain.model.Station
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Search over the radio-browser.info community directory (~58k stations).
* Read-only endpoint, no API key. `url_resolved` is the redirect-followed
* stream URL, which plays more reliably than the raw `url`.
*/
@Singleton
class RadioBrowserRepository @Inject constructor() {
@Serializable
private class ApiStation(
@SerialName("stationuuid") val uuid: String,
val name: String,
@SerialName("url_resolved") val urlResolved: String,
val tags: String = "",
val votes: Int = 0,
val codec: String = "",
val bitrate: Int = 0,
@SerialName("countrycode") val countryCode: String = "",
val favicon: String = "",
)
private val client = OkHttpClient.Builder().build()
private val json = Json { ignoreUnknownKeys = true }
suspend fun search(query: String, limit: Int = 30): List<Station> =
withContext(Dispatchers.IO) {
val url = BASE
.toHttpUrl()
.newBuilder()
.addPathSegments("json/stations/search")
.addQueryParameter("name", query.trim())
.addQueryParameter("hidebroken", "true")
.addQueryParameter("limit", limit.toString())
.addQueryParameter("order", "clickcount")
.addQueryParameter("reverse", "true")
.build()
.toString()
val request = Request.Builder()
.url(url)
.header("User-Agent", "SatsAmp/1.0 (personal radio player)")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException("Directory search failed: ${response.code}")
}
val body = response.body?.string().orEmpty()
json.decodeFromString<List<ApiStation>>(body).map { it.toStation() }
}
}
private fun ApiStation.toStation(): Station {
val firstTag = tags.split(",").firstOrNull { it.isNotBlank() }?.trim().orEmpty()
val quality = listOfNotNull(
codec.takeIf { it.isNotBlank() },
bitrate.takeIf { it > 0 }?.let { "$it kbps" },
).joinToString(" · ")
return Station(
id = "rb-$uuid",
name = name.trim(),
url = urlResolved,
genre = firstTag.ifBlank { "Directory" },
description = listOfNotNull(quality.takeIf { it.isNotBlank() }, countryCode.takeIf { it.isNotBlank() })
.joinToString(" · "),
logoUrl = favicon,
country = countryCode,
)
}
companion object {
private const val BASE = "https://de2.api.radio-browser.info"
}
}

View file

@ -5,6 +5,9 @@ import android.content.SharedPreferences
import com.rada.data.local.DefaultStations
import com.rada.domain.model.Station
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONArray
import org.json.JSONObject
import javax.inject.Inject
@ -61,6 +64,62 @@ class StationRepository @Inject constructor(
fun isRenamed(id: String): Boolean = nameOverrides.containsKey(id)
// ── Favorites ──────────────────────────────────────────────────────
// A favorite stores the full Station so directory stations (which are
// not part of the built-in list) survive the toggle and show offline.
private val _favorites = MutableStateFlow(loadFavorites())
val favorites: StateFlow<List<Station>> = _favorites.asStateFlow()
fun isFavorite(id: String): Boolean = _favorites.value.any { it.id == id }
fun toggleFavorite(station: Station) {
_favorites.value = if (_favorites.value.any { it.id == station.id }) {
_favorites.value.filterNot { it.id == station.id }
} else {
_favorites.value + station
}
saveFavorites(_favorites.value)
}
private fun loadFavorites(): List<Station> {
val json = prefs.getString(KEY_FAVORITE_STATIONS, null) ?: return emptyList()
return try {
val arr = JSONArray(json)
(0 until arr.length()).mapNotNull { i ->
val obj = arr.optJSONObject(i) ?: return@mapNotNull null
Station(
id = obj.getString("id"),
name = obj.getString("name"),
url = obj.getString("url"),
genre = obj.optString("genre", ""),
description = obj.optString("description", ""),
logoUrl = obj.optString("logoUrl", ""),
country = obj.optString("country", ""),
)
}
} catch (_: Exception) {
emptyList()
}
}
private fun saveFavorites(list: List<Station>) {
val arr = JSONArray()
list.forEach { s ->
arr.put(
JSONObject()
.put("id", s.id)
.put("name", s.name)
.put("url", s.url)
.put("genre", s.genre)
.put("description", s.description)
.put("logoUrl", s.logoUrl)
.put("country", s.country),
)
}
prefs.edit().putString(KEY_FAVORITE_STATIONS, arr.toString()).apply()
}
fun getOriginalStationName(id: String): String? =
DefaultStations.getById(id)?.name ?: loadCustomStations().find { it.id == id }?.name
@ -127,5 +186,6 @@ class StationRepository @Inject constructor(
companion object {
private const val KEY_CUSTOM_STATIONS = "custom_stations"
private const val KEY_NAME_OVERRIDES = "station_name_overrides"
private const val KEY_FAVORITE_STATIONS = "favorite_stations"
}
}

View file

@ -74,6 +74,22 @@ class PlaybackManager(
private val _isPlayingFlow = MutableStateFlow(false)
val isPlayingFlow: StateFlow<Boolean> = _isPlayingFlow.asStateFlow()
/** True while a local library song plays (vs a radio stream). */
private val _isLocalFlow = MutableStateFlow(false)
val isLocalFlow: StateFlow<Boolean> = _isLocalFlow.asStateFlow()
/** (title, artist) of the playing local file; null while streaming. */
private val _localMediaFlow = MutableStateFlow<Pair<String, String>?>(null)
val localMediaFlow: StateFlow<Pair<String, String>?> = _localMediaFlow.asStateFlow()
/**
* Last non-empty ICY metadata for the current stream, cached so screens
* that (re)subscribe after the metadata event (player reopened mid-song,
* a stream that sends metadata only once at connect) still show the track.
*/
private val _lastStreamMetaFlow = MutableStateFlow<StreamMetadata?>(null)
val lastStreamMetaFlow: StateFlow<StreamMetadata?> = _lastStreamMetaFlow.asStateFlow()
private val _bufferStateFlow = MutableStateFlow(BufferState.IDLE)
val bufferStateFlow: StateFlow<BufferState> = _bufferStateFlow.asStateFlow()
@ -125,6 +141,9 @@ class PlaybackManager(
currentStationName = name
_currentStationUrlFlow.value = url
_currentStationNameFlow.value = name
_isLocalFlow.value = false
_localMediaFlow.value = null
_lastStreamMetaFlow.value = null
val ctrl = controller ?: return
stopAll()
@ -152,6 +171,12 @@ class PlaybackManager(
fun playLocalFile(filePath: String, title: String, artist: String) {
currentStationUrl = null
currentStationName = ""
// Publish so the shared NowPlaying bar shows library playback too.
_currentStationNameFlow.value = if (artist.isBlank()) title else "$title — $artist"
_currentStationUrlFlow.value = null
_isLocalFlow.value = true
_localMediaFlow.value = title to artist
_lastStreamMetaFlow.value = null
val ctrl = controller ?: return
stopAll()
@ -258,6 +283,9 @@ class PlaybackManager(
// not an absolute one — that mismatch is what corrupted
// boundaries after the buffer wrapped.
metadataTimeline.record(metadata, audioBytesAtMetadata)
if (metadata.artist.isNotEmpty() || metadata.title.isNotEmpty()) {
_lastStreamMetaFlow.value = metadata
}
listener?.onIcyMetadata(metadata)
listener?.onMetadataUpdate(metadata.artist, metadata.title)
}

View file

@ -14,6 +14,19 @@ data class StreamMetadata(
get() = if (artist.isNotEmpty() && title.isNotEmpty()) title else ""
companion object {
// Server-side template junk, e.g. AzuraCast sending ' - [{{year}}]'
// with the placeholder never substituted. Such fields are not metadata.
private val PlaceholderRegex = Regex("""\{\{[^}]*\}\}""")
/** True when [s] is empty or only placeholder/delimiter residue. */
private fun isPlaceholder(s: String): Boolean {
val cleaned = PlaceholderRegex.replace(s, "")
.trim()
.removeSurrounding("[", "]")
.trim()
return cleaned.isEmpty()
}
fun parseStreamTitle(raw: String): StreamMetadata {
val cleaned = raw.trim().removeSuffix(";")
var artist = ""
@ -31,6 +44,8 @@ data class StreamMetadata(
title = value
}
}
if (isPlaceholder(artist)) artist = ""
if (isPlaceholder(title)) title = ""
return StreamMetadata(
artist = artist,

View file

@ -0,0 +1,35 @@
package com.rada.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.rada.R
/**
* The app icon as a home control. Top-left on every surface: on the player
* hub it reads as the brand mark; on detail screens it returns the user home.
* Standard 48dp IconButton target; the icon itself carries the launcher art.
*/
@Composable
fun AppHomeButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
IconButton(onClick = onClick, modifier = modifier) {
Image(
painter = painterResource(R.mipmap.ic_launcher),
contentDescription = "Home",
modifier = Modifier
.size(30.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Fit,
)
}
}

View file

@ -77,12 +77,14 @@ import com.rada.data.local.entity.PlaylistEntity
import com.rada.data.local.entity.SavedSongEntity
import com.rada.data.repository.SortOrder
import com.rada.ui.theme.AppearanceDialog
import com.rada.ui.components.AppHomeButton
import com.rada.ui.theme.LocalTokens
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryScreen(
onBack: () -> Unit,
onHome: () -> Unit = {},
initialPlaylistId: Long? = null,
viewModel: LibraryViewModel = hiltViewModel(),
) {
@ -141,8 +143,11 @@ fun LibraryScreen(
TopAppBar(
title = { Text("Library") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
Row(horizontalArrangement = Arrangement.Start) {
AppHomeButton(onClick = onHome)
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
actions = {

View file

@ -12,6 +12,13 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navArgument
import com.rada.ui.theme.LocalReduceMotion
import com.rada.ui.theme.detailEnter
import com.rada.ui.theme.detailExit
import com.rada.ui.theme.detailPopEnter
import com.rada.ui.theme.detailPopExit
import com.rada.ui.theme.hubEnter
import com.rada.ui.theme.hubExit
import com.rada.ui.library.LibraryScreen
import com.rada.ui.player.NowPlayingBar
import com.rada.ui.player.PlayerScreen
@ -24,6 +31,7 @@ fun NavGraph(navController: NavHostController) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val showNowPlayingBar = currentRoute != Screen.Player.route
val reduce = LocalReduceMotion.current
Column(modifier = Modifier.fillMaxSize()) {
NavHost(
@ -31,7 +39,13 @@ fun NavGraph(navController: NavHostController) {
startDestination = Screen.Player.route,
modifier = Modifier.weight(1f),
) {
composable(Screen.Player.route) {
composable(
Screen.Player.route,
enterTransition = { hubEnter(reduce) },
exitTransition = { hubExit(reduce) },
popEnterTransition = { hubEnter(reduce) },
popExitTransition = { hubExit(reduce) },
) {
PlayerScreen(
onNavigateToStations = { navController.navigate(Screen.Stations.route) },
onNavigateToLibrary = { navController.navigate(Screen.Library.createRoute()) },
@ -47,28 +61,54 @@ fun NavGraph(navController: NavHostController) {
defaultValue = -1L
}
),
enterTransition = { detailEnter(reduce) },
exitTransition = { detailExit(reduce) },
popEnterTransition = { detailPopEnter(reduce) },
popExitTransition = { detailPopExit(reduce) },
) { backStackEntry ->
val playlistId = backStackEntry.arguments?.getLong("playlistId")?.takeIf { it > 0 }
LibraryScreen(
onBack = { navController.popBackStack() },
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
initialPlaylistId = playlistId,
)
}
composable(Screen.Stations.route) {
composable(
Screen.Stations.route,
enterTransition = { detailEnter(reduce) },
exitTransition = { detailExit(reduce) },
popEnterTransition = { detailPopEnter(reduce) },
popExitTransition = { detailPopExit(reduce) },
) {
StationBrowserScreen(
onBack = { navController.popBackStack() },
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
onStationSelected = { navController.popBackStack() },
)
}
composable(Screen.Streams.route) {
composable(
Screen.Streams.route,
enterTransition = { detailEnter(reduce) },
exitTransition = { detailExit(reduce) },
popEnterTransition = { detailPopEnter(reduce) },
popExitTransition = { detailPopExit(reduce) },
) {
StreamsScreen(
onBack = { navController.popBackStack() },
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
onStreamSelected = { navController.popBackStack() },
)
}
composable(Screen.Playlists.route) {
composable(
Screen.Playlists.route,
enterTransition = { detailEnter(reduce) },
exitTransition = { detailExit(reduce) },
popEnterTransition = { detailPopEnter(reduce) },
popExitTransition = { detailPopExit(reduce) },
) {
PlaylistManagerScreen(
onBack = { navController.popBackStack() },
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
onPlaylistSelected = { playlistId ->
navController.navigate(Screen.Library.createRoute(playlistId))
},

View file

@ -1,8 +1,9 @@
package com.rada.ui.player
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import com.rada.ui.theme.LocalReduceMotion
import com.rada.ui.theme.barEnter
import com.rada.ui.theme.barExit
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@ -19,6 +20,7 @@ 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.MusicNote
import androidx.compose.material.icons.filled.Radio
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
@ -49,16 +51,18 @@ fun NowPlayingBar(
viewModel: NowPlayingViewModel = hiltViewModel(),
) {
val tokens = LocalTokens.current
val reduce = LocalReduceMotion.current
val stationName by viewModel.currentStationName.collectAsState()
val isPlaying by viewModel.isPlaying.collectAsState()
val bufferState by viewModel.bufferState.collectAsState()
val isLocal by viewModel.isLocal.collectAsState()
val isVisible = stationName.isNotEmpty()
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }),
enter = barEnter(reduce),
exit = barExit(reduce),
) {
Column(
modifier = Modifier
@ -104,9 +108,9 @@ fun NowPlayingBar(
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Radio,
contentDescription = null,
tint = Color.White,
if (isLocal) Icons.Default.MusicNote else Icons.Default.Radio,
contentDescription = if (isLocal) "Now playing" else "Now playing station",
tint = LocalTokens.current.accentOnFill,
modifier = Modifier.size(24.dp),
)
}

View file

@ -15,6 +15,7 @@ class NowPlayingViewModel @Inject constructor(
val currentStationUrl: StateFlow<String?> = playbackManager.currentStationUrlFlow
val isPlaying: StateFlow<Boolean> = playbackManager.isPlayingFlow
val bufferState: StateFlow<PlaybackManager.BufferState> = playbackManager.bufferStateFlow
val isLocal: StateFlow<Boolean> = playbackManager.isLocalFlow
fun togglePlayPause() {
playbackManager.togglePlayPause()

View file

@ -5,7 +5,13 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.core.tween
import androidx.compose.animation.togetherWith
import com.rada.ui.theme.LocalReduceMotion
import com.rada.ui.theme.MotionArrival
import com.rada.ui.theme.MotionExit
import com.rada.ui.theme.metadataEnter
import com.rada.ui.theme.metadataExit
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -16,6 +22,7 @@ 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.statusBarsPadding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
@ -30,6 +37,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
@ -37,6 +45,7 @@ import androidx.compose.material.icons.filled.Podcasts
import androidx.compose.material.icons.filled.Radio
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.Stop
@ -47,6 +56,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
@ -89,7 +99,9 @@ import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.domain.model.Station
import com.rada.player.PlaybackManager
import com.rada.ui.components.AppHomeButton
import com.rada.ui.theme.LocalTokens
import com.rada.ui.theme.AppearanceDialog
import com.rada.ui.theme.GradientEnd
import com.rada.ui.theme.GradientMid
import com.rada.ui.theme.PremiumCyan
@ -107,6 +119,7 @@ fun PlayerScreen(
val importMessage by viewModel.importMessage.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var showImportDialog by remember { mutableStateOf(false) }
var showAppearance by remember { mutableStateOf(false) }
val importSongsPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments()
@ -183,9 +196,42 @@ fun PlayerScreen(
)
}
if (showAppearance) {
AppearanceDialog(onDismiss = { showAppearance = false })
}
Scaffold(
containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
// Home slot: the app icon top-left on every surface. On the hub
// there is nowhere further home to go, so it reads as the brand
// mark; detail screens wire it to pop back here.
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding(),
verticalAlignment = Alignment.CenterVertically,
) {
AppHomeButton(onClick = { /* already home */ })
Text(
text = "SatsAmp",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = LocalTokens.current.text,
)
}
},
floatingActionButton = {
// Appearance settings reachable from home too (same dialog as Library).
FloatingActionButton(
onClick = { showAppearance = true },
containerColor = LocalTokens.current.panel.copy(alpha = 0.94f),
contentColor = LocalTokens.current.textSecondary,
) {
Icon(Icons.Default.Settings, contentDescription = "Appearance settings")
}
},
bottomBar = {
BottomAppBar(
containerColor = LocalTokens.current.panel.copy(alpha = 0.94f),
@ -238,7 +284,7 @@ fun PlayerScreen(
}
},
) { padding ->
if (uiState.currentStation != null) {
if (uiState.currentStation != null || uiState.isLocal) {
NowPlayingView(
uiState = uiState,
viewModel = viewModel,
@ -388,14 +434,18 @@ private fun WaveGraphic(modifier: Modifier = Modifier) {
)
}
// Draw subtle glow at center
// Ambient glow behind the waves — wide, faint, no visible disc edge.
drawCircle(
brush = Brush.radialGradient(
colors = listOf(accentGlow.copy(alpha = 0.15f), Color.Transparent),
colorStops = arrayOf(
0f to accentGlow.copy(alpha = 0.06f),
0.55f to accentGlow.copy(alpha = 0.025f),
1f to Color.Transparent,
),
center = Offset(width / 2, centerY),
radius = width / 3,
radius = width * 0.75f,
),
radius = width / 3,
radius = width * 0.75f,
center = Offset(width / 2, centerY),
)
}
@ -407,6 +457,7 @@ private fun NowPlayingView(
viewModel: PlayerViewModel,
modifier: Modifier = Modifier,
) {
val reduce = LocalReduceMotion.current
Column(
modifier = modifier
.fillMaxSize()
@ -422,13 +473,64 @@ private fun NowPlayingView(
bufferState = uiState.bufferState,
)
}
// Local library playback: a card for the song instead of a station.
if (uiState.currentStation == null && uiState.isLocal) {
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = RoundedCornerShape(16.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(LocalTokens.current.accentFill.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.MusicNote,
contentDescription = null,
tint = LocalTokens.current.accentFill,
modifier = Modifier.size(24.dp),
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = uiState.localTitle,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (uiState.localArtist.isNotEmpty()) {
Text(
text = uiState.localArtist,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
Spacer(modifier = Modifier.height(32.dp))
// Artist/Station name
AnimatedContent(
targetState = uiState.artist,
transitionSpec = { fadeIn() togetherWith fadeOut() },
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
label = "artist",
) { artist ->
Text(
@ -444,7 +546,7 @@ private fun NowPlayingView(
// Song title
AnimatedContent(
targetState = uiState.title,
transitionSpec = { fadeIn() togetherWith fadeOut() },
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
label = "title",
) { title ->
if (title.isNotEmpty()) {
@ -459,7 +561,7 @@ private fun NowPlayingView(
)
} else if (uiState.isPlaying && uiState.currentStation != null) {
Text(
text = if (uiState.hasMetadata) "" else "No metadata",
text = if (uiState.hasMetadata) "" else "No track info from this station",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
@ -479,14 +581,27 @@ private fun NowPlayingView(
Spacer(modifier = Modifier.height(32.dp))
// Playback controls
// Playback controls — conventional transport order: stop · prev · play · next
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = { viewModel.stop() },
enabled = uiState.currentStation != null || uiState.isLocal,
) {
Icon(
Icons.Default.Stop,
contentDescription = "Stop",
modifier = Modifier.size(28.dp),
)
}
Spacer(modifier = Modifier.width(12.dp))
IconButton(
onClick = { viewModel.previousStation() },
enabled = uiState.currentStation != null,
enabled = uiState.currentStation != null || uiState.isLocal,
) {
Icon(
Icons.Default.SkipPrevious,
@ -497,42 +612,48 @@ private fun NowPlayingView(
Spacer(modifier = Modifier.width(16.dp))
IconButton(
onClick = { viewModel.stop() },
enabled = uiState.currentStation != null,
) {
Icon(
Icons.Default.Stop,
contentDescription = "Stop",
modifier = Modifier.size(32.dp),
)
}
Spacer(modifier = Modifier.width(16.dp))
FilledIconButton(
onClick = { viewModel.togglePlayPause() },
enabled = uiState.currentStation != null,
enabled = uiState.currentStation != null || uiState.isLocal,
modifier = Modifier.size(72.dp),
shape = CircleShape,
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
if (uiState.isPlaying) {
AnimatedContent(
targetState = uiState.isPlaying,
transitionSpec = {
if (reduce)
fadeIn(tween(0)) togetherWith fadeOut(tween(0))
else
scaleIn(tween(130, easing = MotionArrival), initialScale = 0.7f) +
fadeIn(tween(90)) togetherWith
scaleOut(tween(90, easing = MotionExit), targetScale = 0.7f) +
fadeOut(tween(90))
},
label = "playPause",
) { playing ->
Icon(
Icons.Default.Pause,
contentDescription = "Pause",
modifier = Modifier.size(36.dp),
)
} else {
Icon(
Icons.Default.PlayArrow,
contentDescription = "Play",
if (playing) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (playing) "Pause" else "Play",
modifier = Modifier.size(36.dp),
)
}
}
Spacer(modifier = Modifier.width(16.dp))
IconButton(
onClick = { viewModel.nextStation() },
enabled = uiState.currentStation != null,
) {
Icon(
Icons.Default.SkipNext,
contentDescription = "Next station",
modifier = Modifier.size(32.dp),
)
}
}
}
}
@ -691,6 +812,7 @@ private fun SaveSongButton(
saveState: PlaybackManager.SaveState,
onClick: () -> Unit,
) {
val reduce = LocalReduceMotion.current
val containerColor = when {
saveState == PlaybackManager.SaveState.SAVING -> MaterialTheme.colorScheme.secondary
saveState == PlaybackManager.SaveState.SUCCESS -> Color(0xFF10B981)
@ -724,8 +846,11 @@ private fun SaveSongButton(
AnimatedContent(
targetState = saveState,
transitionSpec = {
scaleIn(initialScale = 0.8f) + fadeIn() togetherWith
scaleOut(targetScale = 0.8f) + fadeOut()
if (reduce)
fadeIn(tween(140)) togetherWith fadeOut(tween(140))
else
scaleIn(initialScale = 0.8f) + fadeIn() togetherWith
scaleOut(targetScale = 0.8f) + fadeOut()
},
label = "saveIcon",
) { state ->

View file

@ -139,9 +139,27 @@ class PlayerViewModel @Inject constructor(
currentStation = playbackManager.currentStationUrl?.let { url ->
Station(id = url, name = playbackManager.currentStationName, url = url)
},
isLocal = playbackManager.isLocalFlow.value,
localTitle = playbackManager.localMediaFlow.value?.first.orEmpty(),
localArtist = playbackManager.localMediaFlow.value?.second.orEmpty(),
isPlaying = playbackManager.isPlaying,
)
}
restoreCachedStreamMeta()
}
/** Re-apply the last ICY metadata so a reopened screen isn't blank. */
private fun restoreCachedStreamMeta() {
playbackManager.lastStreamMetaFlow.value?.let { meta ->
_uiState.update {
it.copy(
artist = meta.displayArtist.ifEmpty { it.artist },
title = meta.displayTitle,
hasMetadata = true,
lastMetadata = meta,
)
}
}
}
fun playStation(station: Station) {
@ -255,16 +273,23 @@ class PlayerViewModel @Inject constructor(
fun syncPlaybackState() {
playbackManager.listener = playbackListener
val url = playbackManager.currentStationUrl
val isLocal = playbackManager.isLocalFlow.value
val local = playbackManager.localMediaFlow.value
_uiState.update {
it.copy(
stations = stationRepository.getAll(),
currentStation = url?.let { u ->
Station(id = u, name = playbackManager.currentStationName, url = u)
},
isLocal = isLocal,
localTitle = local?.first.orEmpty(),
localArtist = local?.second.orEmpty(),
isPlaying = playbackManager.isPlaying,
artist = playbackManager.currentStationName,
artist = if (isLocal) local?.second.orEmpty() else playbackManager.currentStationName,
title = if (isLocal) local?.first.orEmpty() else it.title,
)
}
if (!isLocal) restoreCachedStreamMeta()
}
override fun onCleared() {
@ -275,6 +300,9 @@ class PlayerViewModel @Inject constructor(
data class PlayerUiState(
val currentStation: Station? = null,
val isLocal: Boolean = false,
val localTitle: String = "",
val localArtist: String = "",
val isPlaying: Boolean = false,
val artist: String = "",
val title: String = "",

View file

@ -57,12 +57,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.data.local.entity.PlaylistEntity
import com.rada.ui.components.AppHomeButton
import com.rada.ui.theme.LocalTokens
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlaylistManagerScreen(
onBack: () -> Unit,
onHome: () -> Unit = {},
onPlaylistSelected: (Long) -> Unit = {},
viewModel: PlaylistManagerViewModel = hiltViewModel(),
) {
@ -133,8 +135,11 @@ fun PlaylistManagerScreen(
TopAppBar(
title = { Text("Playlists") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
Row(horizontalArrangement = Arrangement.Start) {
AppHomeButton(onClick = onHome)
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
actions = {

View file

@ -20,6 +20,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Radio
@ -27,6 +31,7 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -49,9 +54,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.domain.model.Station
import com.rada.ui.components.AppHomeButton
import com.rada.ui.theme.LocalTokens
private val genreColors = mapOf(
@ -75,11 +82,15 @@ private val genreColors = mapOf(
@Composable
fun StationBrowserScreen(
onBack: () -> Unit,
onHome: () -> Unit = {},
onStationSelected: () -> Unit = {},
viewModel: StationBrowserViewModel = hiltViewModel(),
) {
val stationsByGenre by viewModel.stationsByGenre.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
val favorites by viewModel.favorites.collectAsState()
val directoryState by viewModel.directoryState.collectAsState()
val playingUrl by viewModel.playingUrl.collectAsState()
var showAddDialog by remember { mutableStateOf(false) }
var renameTarget by remember { mutableStateOf<Station?>(null) }
@ -115,8 +126,11 @@ fun StationBrowserScreen(
TopAppBar(
title = { Text("Stations") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
Row(horizontalArrangement = Arrangement.Start) {
AppHomeButton(onClick = onHome)
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
actions = {
@ -162,6 +176,27 @@ fun StationBrowserScreen(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// ── Favorites row: pinned above everything ──
if (favorites.isNotEmpty()) {
item { SectionHeader("Starred", Icons.Default.Star) }
items(favorites, key = { "fav-${it.id}" }) { station ->
StationCard(
station = station,
isCustom = viewModel.isCustomStation(station.id),
isFavorite = true,
isPlaying = playingUrl == station.url,
onClick = {
viewModel.playFavorite(station)
onStationSelected()
},
onToggleFavorite = { viewModel.toggleFavorite(station) },
onRename = { renameTarget = station },
onDelete = { viewModel.removeCustomStation(station.id) },
)
}
item { Spacer(modifier = Modifier.height(8.dp)) }
}
stationsByGenre.forEach { (genre, stations) ->
item {
GenreHeader(genre = genre, count = stations.size)
@ -170,10 +205,13 @@ fun StationBrowserScreen(
StationCard(
station = station,
isCustom = viewModel.isCustomStation(station.id),
isFavorite = viewModel.isFavorite(station.id),
isPlaying = playingUrl == station.url,
onClick = {
viewModel.selectStation(station)
onStationSelected()
},
onToggleFavorite = { viewModel.toggleFavorite(station) },
onRename = { renameTarget = station },
onDelete = {
viewModel.removeCustomStation(station.id)
@ -184,6 +222,72 @@ fun StationBrowserScreen(
Spacer(modifier = Modifier.height(8.dp))
}
}
// ── Directory results (radio-browser.info) ──
when (val ds = directoryState) {
DirectoryState.Idle -> Unit
DirectoryState.Loading -> item {
Column {
DirectorySectionHeader("Searching 58,000+ stations…")
Row(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(22.dp),
strokeWidth = 2.dp,
color = LocalTokens.current.accentText,
)
}
}
}
is DirectoryState.Error -> item {
Column {
DirectorySectionHeader("Directory")
Text(
text = "${ds.message}\nCheck your connection and search again.",
style = MaterialTheme.typography.bodySmall,
color = LocalTokens.current.textSecondary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
is DirectoryState.Results -> {
if (ds.stations.isNotEmpty()) {
item { DirectorySectionHeader("From the directory") }
items(ds.stations, key = { "rb-${it.id}" }) { station ->
StationCard(
station = station,
isCustom = false,
isFavorite = viewModel.isFavorite(station.id),
isPlaying = playingUrl == station.url,
onClick = {
viewModel.selectStation(station)
onStationSelected()
},
onToggleFavorite = { viewModel.toggleFavorite(station) },
onRename = {},
onDelete = {},
onAdd = { viewModel.addDiscoveredStation(station) },
)
}
} else {
item {
Column {
DirectorySectionHeader("From the directory")
Text(
text = "No directory matches.",
style = MaterialTheme.typography.bodySmall,
color = LocalTokens.current.textSecondary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
}
}
}
}
}
}
@ -363,37 +467,45 @@ private fun GenreHeader(genre: String, count: Int) {
private fun StationCard(
station: Station,
isCustom: Boolean,
isFavorite: Boolean,
isPlaying: Boolean,
onClick: () -> Unit,
onToggleFavorite: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
onAdd: (() -> Unit)? = null,
) {
val tokens = LocalTokens.current
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = LocalTokens.current.glassFillStrong,
containerColor = if (isPlaying) tokens.accentSoft else tokens.glassFillStrong,
),
border = if (isPlaying) {
androidx.compose.foundation.BorderStroke(1.dp, tokens.accentFill.copy(alpha = 0.6f))
} else null,
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(LocalTokens.current.accentFill.copy(alpha = 0.15f)),
.background(tokens.accentFill.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Radio,
if (isPlaying) Icons.Default.PlayArrow else Icons.Default.Radio,
contentDescription = null,
tint = LocalTokens.current.accentText,
tint = tokens.accentText,
modifier = Modifier.size(20.dp),
)
}
@ -402,35 +514,108 @@ private fun StationCard(
Text(
text = station.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = LocalTokens.current.text,
fontWeight = if (isPlaying) FontWeight.Bold else FontWeight.SemiBold,
color = tokens.text,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (station.genre.isNotEmpty()) {
val subtitle = station.description.ifBlank { station.genre }
if (subtitle.isNotEmpty()) {
Text(
text = station.genre,
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = LocalTokens.current.textSecondary,
color = tokens.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
IconButton(onClick = onRename) {
IconButton(onClick = onToggleFavorite, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Default.Edit,
contentDescription = "Rename",
tint = LocalTokens.current.textSecondary,
imageVector = if (isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
contentDescription = if (isFavorite) "Unstar" else "Star",
tint = if (isFavorite) tokens.accentFill else tokens.textSecondary,
modifier = Modifier.size(20.dp),
)
}
if (isCustom) {
IconButton(onClick = onDelete) {
// Directory stations: offer saving into the local list.
if (onAdd != null) {
IconButton(onClick = onAdd, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove",
tint = LocalTokens.current.textSecondary,
Icons.Default.Add,
contentDescription = "Save to stations",
tint = tokens.textSecondary,
modifier = Modifier.size(20.dp),
)
}
} else {
IconButton(onClick = onRename, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Default.Edit,
contentDescription = "Rename",
tint = tokens.textSecondary,
modifier = Modifier.size(20.dp),
)
}
if (isCustom) {
IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove",
tint = tokens.textSecondary,
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
}
@Composable
private fun SectionHeader(title: String, icon: androidx.compose.ui.graphics.vector.ImageVector) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
icon,
contentDescription = null,
tint = LocalTokens.current.accentFill,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = LocalTokens.current.accentText,
)
}
}
@Composable
private fun DirectorySectionHeader(title: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Language,
contentDescription = null,
tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = LocalTokens.current.textSecondary,
)
}
}

View file

@ -2,22 +2,37 @@ package com.rada.ui.stations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rada.data.repository.RadioBrowserRepository
import com.rada.data.repository.StationRepository
import com.rada.domain.model.Station
import com.rada.player.PlaybackManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
/** Directory (radio-browser.info) search state. */
sealed interface DirectoryState {
data object Idle : DirectoryState
data object Loading : DirectoryState
data class Results(val stations: List<Station>) : DirectoryState
data class Error(val message: String) : DirectoryState
}
@OptIn(FlowPreview::class)
@HiltViewModel
class StationBrowserViewModel @Inject constructor(
private val stationRepository: StationRepository,
private val radioBrowserRepository: RadioBrowserRepository,
private val playbackManager: PlaybackManager,
) : ViewModel() {
@ -26,6 +41,11 @@ class StationBrowserViewModel @Inject constructor(
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
val favorites: StateFlow<List<Station>> = stationRepository.favorites
/** URL of whatever is playing now, so the list can mark the active row. */
val playingUrl: StateFlow<String?> = playbackManager.currentStationUrlFlow
val stations: StateFlow<List<Station>> = combine(_allStations, _searchQuery) { list, query ->
val filtered = if (query.isBlank()) list
else list.filter {
@ -48,6 +68,33 @@ class StationBrowserViewModel @Inject constructor(
.toSortedMap(compareBy { it.lowercase() })
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
// ── Directory search (radio-browser.info, ~58k stations) ───────────
private val _directoryState = MutableStateFlow<DirectoryState>(DirectoryState.Idle)
val directoryState: StateFlow<DirectoryState> = _directoryState.asStateFlow()
init {
// Debounced: search the wider directory only once typing settles.
_searchQuery
.debounce(450)
.distinctUntilChanged()
.onEach { query ->
val q = query.trim()
when {
q.length < 2 -> _directoryState.value = DirectoryState.Idle
else -> {
_directoryState.value = DirectoryState.Loading
_directoryState.value = try {
DirectoryState.Results(radioBrowserRepository.search(q))
} catch (e: Exception) {
DirectoryState.Error(e.message ?: "Directory search failed")
}
}
}
}
.launchIn(viewModelScope)
}
fun search(query: String) {
_searchQuery.value = query
}
@ -56,6 +103,15 @@ class StationBrowserViewModel @Inject constructor(
playbackManager.playStation(station.url, station.name)
}
fun isFavorite(id: String): Boolean = stationRepository.isFavorite(id)
fun toggleFavorite(station: Station) = stationRepository.toggleFavorite(station)
/** Play a favorite and, if it is new, keep it in the custom list too. */
fun playFavorite(station: Station) {
playbackManager.playStation(station.url, station.name)
}
fun addCustomStation(name: String, url: String) {
if (name.isBlank() || url.isBlank()) return
val id = "custom-${System.currentTimeMillis()}"
@ -69,6 +125,13 @@ class StationBrowserViewModel @Inject constructor(
_allStations.value = stationRepository.getAll()
}
/** Save a directory/discovered station into the local list as "Custom". */
fun addDiscoveredStation(station: Station) {
if (stationRepository.getById(station.id) != null) return
stationRepository.addStation(station.copy(genre = station.genre.ifBlank { "Custom" }))
_allStations.value = stationRepository.getAll()
}
fun removeCustomStation(id: String) {
stationRepository.removeStation(id)
_allStations.value = stationRepository.getAll()

View file

@ -55,12 +55,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.domain.model.StreamEntry
import com.rada.ui.components.AppHomeButton
import com.rada.ui.theme.LocalTokens
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StreamsScreen(
onBack: () -> Unit,
onHome: () -> Unit = {},
onStreamSelected: () -> Unit = {},
viewModel: StreamsViewModel = hiltViewModel(),
) {
@ -101,8 +103,11 @@ fun StreamsScreen(
TopAppBar(
title = { Text("Streams") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
Row(horizontalArrangement = Arrangement.Start) {
AppHomeButton(onClick = onHome)
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
actions = {

View file

@ -0,0 +1,109 @@
package com.rada.ui.theme
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.runtime.staticCompositionLocalOf
/**
* SatsAmp motion system — one authored moment (the hub hop into the player),
* quiet supporting continuity everywhere else. Exits are always faster than
* entrances; arrivals use exponential ease-out, never bounce.
*
* `reduce` mirrors the system "Remove animations" setting (ANIMATOR_DURATION_SCALE):
* spatial movement is removed and only a short opacity cut remains, which keeps
* state changes legible without motion.
*/
/** Confident arrival — exponential ease-out (animate.md's suggested curve). */
val MotionArrival = CubicBezierEasing(0.16f, 1f, 0.3f, 1f)
/** M3 emphasized accelerate — used for exits, which should leave quickly. */
val MotionExit = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f)
/** M3 standard — routine state changes. */
val MotionStandard = CubicBezierEasing(0.2f, 0f, 0f, 1f)
// Duration budget (animate.md timing table).
private const val FeedbackMs = 130
private const val StateMs = 220
private const val ViewMs = 340
private const val ExitFastMs = 140
/** True when the user has disabled animations system-wide. Provided by SatsAmpTheme. */
val LocalReduceMotion = staticCompositionLocalOf { false }
// ── Hub (Player): the one authored transition ─────────────────────────────
// Entering the hub, content comes into tune: a slight scale settle under a
// short fade. Leaving is just a quick fade — exits faster than entrances.
fun hubEnter(reduce: Boolean): EnterTransition =
if (reduce) fadeIn(tween(ExitFastMs))
else fadeIn(tween(StateMs, easing = MotionArrival)) +
scaleIn(tween(ViewMs, easing = MotionArrival), initialScale = 0.94f)
fun hubExit(reduce: Boolean): ExitTransition =
if (reduce) fadeOut(tween(ExitFastMs))
else fadeOut(tween(ExitFastMs, easing = MotionExit)) +
scaleOut(tween(StateMs, easing = MotionExit), targetScale = 0.97f)
// ── Detail screens: shared-axis continuity ────────────────────────────────
// Stations / Streams / Playlists / Library are pushed to the right of the hub
// and pop back to the left, so direction teaches the hierarchy. Slide is a
// quarter-travel, not a full page wipe — fast screens stay legible mid-motion.
fun detailEnter(reduce: Boolean): EnterTransition =
if (reduce) fadeIn(tween(ExitFastMs))
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { it / 4 } +
fadeIn(tween(StateMs, easing = MotionArrival))
fun detailExit(reduce: Boolean): ExitTransition =
if (reduce) fadeOut(tween(ExitFastMs))
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { -it / 8 } +
fadeOut(tween(ExitFastMs, easing = MotionExit))
fun detailPopEnter(reduce: Boolean): EnterTransition =
if (reduce) fadeIn(tween(ExitFastMs))
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { -it / 4 } +
fadeIn(tween(StateMs, easing = MotionArrival))
fun detailPopExit(reduce: Boolean): ExitTransition =
if (reduce) fadeOut(tween(ExitFastMs))
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { it / 8 } +
fadeOut(tween(ExitFastMs, easing = MotionExit))
// ── Now-playing bar ───────────────────────────────────────────────────────
fun barEnter(reduce: Boolean): EnterTransition =
if (reduce) fadeIn(tween(ExitFastMs))
else slideInVertically(tween(ViewMs, easing = MotionArrival)) { it } +
fadeIn(tween(StateMs, easing = MotionArrival))
fun barExit(reduce: Boolean): ExitTransition =
if (reduce) fadeOut(tween(ExitFastMs))
else fadeOut(tween(ExitFastMs, easing = MotionExit)) +
slideOutVertically(tween(StateMs, easing = MotionExit)) { it }
// ── Metadata change: the dial finds the station ───────────────────────────
// New track/artist text rises a third of a line into place; the old text only
// fades, fast. Two text fields animating identically would read as one event.
fun metadataEnter(reduce: Boolean): EnterTransition =
if (reduce) fadeIn(tween(ExitFastMs))
else slideInVertically(tween(StateMs, easing = MotionArrival)) { it / 3 } +
fadeIn(tween(StateMs, easing = MotionArrival))
fun metadataExit(reduce: Boolean): ExitTransition =
fadeOut(tween(ExitFastMs, easing = MotionExit))
/** Duration for press/feedback animations, zeroed under reduce motion. */
fun feedbackDuration(reduce: Boolean): Int = if (reduce) 0 else FeedbackMs

View file

@ -10,12 +10,14 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import android.provider.Settings
val LocalTokens = staticCompositionLocalOf { darkTokens(AccentMode.VIOLET) }
@ -24,6 +26,10 @@ fun SatsAmpTheme(
content: @Composable () -> Unit,
) {
val context = LocalContext.current
// System "Remove animations" (window animator duration scale) → reduce path.
val reduceMotion = Settings.Global.getFloat(
context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f
) == 0f
val dark = when (UiPrefs.mode) {
AppMode.DARK -> true
AppMode.LIGHT -> false
@ -53,7 +59,15 @@ fun SatsAmpTheme(
surfaceTint = tok.accentFill,
)
val colorScheme = if (dark) buildDark(schemeArgs) else buildLight(schemeArgs)
CompositionLocalProvider(LocalTokens provides tok) {
// Surfaces/Scaffolds use Color.Transparent containers, so M3's
// contentColorFor() cannot resolve onX colors and falls back to the root
// LocalContentColor (Color.Black). Provide the theme's own text color so
// uncolored Text/Icon stay legible in dark mode.
CompositionLocalProvider(
LocalTokens provides tok,
LocalContentColor provides tok.text,
LocalReduceMotion provides reduceMotion,
) {
MaterialTheme(colorScheme = colorScheme, content = content)
}
}