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

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
# Impeccable skill engine binaries (re-downloadable via npx impeccable install)
.hermes/

120
DESIGN.md Normal file
View file

@ -0,0 +1,120 @@
---
name: SatsAmp
description: Android radio player with a glass-on-charcoal surface system and four user-selectable AA-verified accents.
colors:
base-top: "#23232B"
base-bottom: "#1B1B22"
panel-dark: "#202029"
paper-top: "#F2F1F7"
paper-bottom: "#E7E5F0"
panel-light: "#FBFAFE"
text-primary-dark: "#FFFFFF"
text-secondary-dark: "#D9D9DE"
text-muted-dark: "#8C8C96"
text-primary-light: "#1B1B22"
violet-fill: "#7C4DFF"
violet-text: "#A78BFF"
blue-fill: "#4C7DFF"
blue-text: "#93B4FF"
green-fill: "#1FA855"
green-text: "#5BD98A"
orange-fill: "#F97316"
orange-text: "#FFB37E"
violet-fill-light: "#6A3DE0"
blue-fill-light: "#2563EB"
green-fill-light: "#0C7A38"
orange-fill-light: "#C2570A"
ink-on-fill: "#141419"
success: "#34C759"
error: "#EF4444"
warning: "#F59E0B"
typography:
display:
fontFamily: "System default (Roboto)"
fontWeight: 700
title:
fontFamily: "System default (Roboto)"
fontWeight: 600
body:
fontFamily: "System default (Roboto)"
fontWeight: 400
caption:
fontFamily: "System default (Roboto)"
fontSize: "12sp"
fontWeight: 400
rounded:
card: "20dp"
input: "14dp"
surface: "12dp"
full: "circle"
components:
button-accent:
backgroundColor: "{colors.violet-fill}"
textColor: "#FFFFFF"
rounded: "{rounded.input}"
save-button:
backgroundColor: "{colors.success}"
textColor: "#FFFFFF"
rounded: "{rounded.full}"
glass-card:
backgroundColor: "rgba(255,255,255,0.06)"
textColor: "{colors.text-primary-dark}"
rounded: "{rounded.card}"
input-glass:
backgroundColor: "rgba(255,255,255,0.09)"
textColor: "{colors.text-primary-dark}"
rounded: "{rounded.input}"
---
## Overview
SatsAmp is a personal Android radio player (Kotlin + Jetpack Compose, Material 3) whose surface language is translucent glass panels over a charcoal gradient (dark) or paper gradient (light), lit by a soft radial accent glow. The user picks one of four accents — violet, blue, green, orange — and one of System/Dark/Light modes at runtime; every screen must resolve through the token set, never through hardcoded colors. Accessibility is a hard floor: every accent/mode combination is AA-verified.
The normative source of truth is `app/src/main/java/com/rada/ui/theme/Tokens.kt` (`UiTokens`, `tokensFor()`), mirrored by `design/glass-radio-theme.css` for web demos. `Theme.kt` bridges the tokens into the M3 `ColorScheme` and provides `LocalTokens`; the M3 scheme is derived, not independent.
## Colors
**Eight real themes** = 4 accents × dark/light, each a `UiTokens` instance of 21 roles read via `LocalTokens.current`:
- **Base:** dark charcoal gradient `#23232B → #1B1B22` with solid panel `#202029`; light paper gradient `#F2F1F7 → #E7E5F0` with panel `#FBFAFE`.
- **Glass layers (dark):** fill `rgba(255,255,255,0.06)`, strong fill `0.09`, decorative border `0.10`, interactive border `0.36` (≥3:1 per WCAG 1.4.11). Light mode inverts to translucent white over paper (`0.62`/`0.78`) with ink borders.
- **textSafe:** a ~90% opaque base-colored overlay always placed under text sitting on glass. Text never touches bare glass.
- **Text:** dark mode white / `#D9D9DE` / `#8C8C96` (muted is the caption floor, ≥4.5:1); light mode `#1B1B22` at 100% / 80% / 62%.
- **Accents:** dark-mode fills violet `#7C4DFF`, blue `#4C7DFF`, green `#1FA855`, orange `#F97316`; light-mode fills are deepened (`#6A3DE0`, `#2563EB`, `#0C7A38`, `#C2570A`) to hold contrast on paper. Each accent defines fill, hover fill, `accentOnFill` (white for violet, ink `#141419` for blue/green/orange in dark — this flip is the AA mechanism), `accentText` for text/icons on surfaces, and `accentSoft` (~16% alpha) for active-state backfills.
- **Status:** success `#34C759` (save/confirm), error `#EF4444`, warning `#F59E0B` — mode-independent constants.
## Typography
System default font (Roboto), no bundled typefaces. Hierarchy comes from `MaterialTheme.typography` M3 roles; weight emphasis clusters at SemiBold/Bold for titles and controls. Only four local `fontSize` overrides exist (12–13sp captions) — prefer M3 roles over new overrides.
## Layout
Single-activity Compose app with bottom navigation across library / stations / streams / player surfaces. Full-bleed `GlassBackground` (base gradient + one radial accent glow anchored upper-right, alpha 0.10–0.14) sits under all content. Panels, not page sections, carry grouping; content scrolls over the fixed background.
## Elevation & Depth
No shadow vocabulary — depth is tonal layering: base gradient → radial glow → translucent glass fill → `textSafe` overlay → content. Compose has no backdrop blur without a library, so "glass" means the translucent layer plus a 1px inner highlight border. Do not introduce `shadowElevation` to fake depth; add a tonal layer instead.
## Shapes
Two named radii: `SatsAmpGlass.rCard` = 20dp (cards, sheets) and `rInput` = 14dp (inputs, small buttons). `CircleShape` is reserved for playback controls, avatars, and record/save FABs. New surfaces should use a named radius, not a new one-off value.
## Components
- **Accent button** — `accentFill` background, `accentOnFill` label, 14dp radius, `accentFillHover` on press.
- **Glass card** — translucent fill + decorative border + 20dp radius; text goes on the `textSafe` overlay.
- **Input field** — `glassFillStrong`, 14dp radius, `borderInteractive` when focused (`focusRing` = `accentText`).
- **Save/record control** — `success` green, circular; the one fixed-color control by design (it reads the same in every accent).
- **Active nav/tab state** — `accentSoft` backfill + `accentText` icon/label, never a filled accent.
**Motion system** (`ui/theme/Motion.kt`): one shared vocabulary — `MotionArrival` (0.16,1,0.3,1) for entrances, `MotionExit` (M3 emphasized-accelerate) for faster exits, `MotionStandard` for state; duration budget 130 feedback / 220 state / 340 view / 140 exit. Navigation is shared-axis (details slide in from the right ±¼ travel, hub hops scale-settle 0.94→1); metadata changes rise ⅓-line in and fast-fade out; `LocalReduceMotion` (system animator scale = 0) reduces every path to a short opacity cut.
## Do's and Don'ts
- **Do** read colors via `LocalTokens.current` or the derived M3 scheme; every new screen must be correct in all 8 theme combinations.
- **Do** place `textSafe` under any text on glass, and keep `textMuted`-equivalent contrast ≥4.5:1.
- **Do** follow the active accent for anything tinted — buttons, glows, active states, brush gradients (use `SatsAmpGradients.accentBrush()`, not the static gradients).
- **Don't** use `SatsAmpGradients.primary`/`primaryHorizontal` or the fixed `PremiumBlue/Cyan/Pink` brand colors — they are violet-locked legacy and break non-violet themes; prefer `accentBrush()`.
- **Don't** pull from the legacy terminal palette (`TerminalGreen`, `TerminalGold`, …) in `Color.kt`; it belongs to no shipped surface.
- **Don't** use `PremiumError` `#EF4444` as text on light backgrounds without darkening — it fails AA on paper (known gap; fix or pair with an icon when used on light).
- **Don't** introduce one-off radii (ad-hoc 12/16dp already exist in places); extend the named set deliberately if the system genuinely needs a new step.

63
PRODUCT.md Normal file
View file

@ -0,0 +1,63 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
android
## Users
A single primary audience: a personal listener on their own Android phone (minSdk 26). Two jobs, co-equal:
1. **Listener** — open the app, tune in to a curated station fast (lofi, synthwave, EDM, phonk, classical, ambient, jazz, rock), and keep music playing in the background.
2. **Collector** — hear a song on air and save it: the app records from the live stream and embeds the detected song metadata on track boundaries, producing a keepable file in the local library.
No accounts, no social layer, no second-user roles.
## Product Purpose
SatsAmp is a personal internet radio player with a built-in "save this song" reflex. Success means: zero-friction playback of a curated station set, and a saved song from live radio feeling like one tap, with correct title/artist baked into the file.
## Positioning
Most radio apps play streams but discard what's on them. SatsAmp treats the live stream as a source of keepable music: it watches ICY metadata, detects song boundaries, records the rolling buffer, and embeds real metadata into the saved file. Combined with a hand-picked station set and no account or setup, the differentiator is capture-from-the-air, not discovery or personalization.
## Operating Context
- Single-user personal device use; headphone and speaker listening.
- Streams are live and can drop; playback is a foreground service with notification controls.
- Library is local (Room DB, `radamp.db`); saved songs, playlists, and imported files live on-device.
- Stations are built-in defaults plus user-added streams (direct URL or M3U playlist import).
## Capabilities and Constraints
- Android app, Kotlin + Jetpack Compose (Material 3), compileSdk 35, minSdk 26, targetSdk 35.
- Live ICY stream metadata, song-boundary detection, rolling audio buffer, metadata embedding on saved files.
- Appearance system is shipped and binding: `UiPrefs` + `UiTokens` — 4 accents (violet / blue / green / orange) × dark/light, each accent AA-contrast verified per fill; live theme switching via the appearance dialog; launcher label SatsAmp (applicationId remains `com.rada`).
- Network use is stream fetching only; no accounts, no analytics, no backend.
- Cleartext HTTP exists in the station list — playback must tolerate mixed http/https sources.
## Brand Commitments
- The current visual identity is user-declared binding: the 4-accent × dark/light token system with AA-verified contrast must be preserved and followed by all future design work.
- Name: SatsAmp.
## Evidence on Hand
- 43 built-in stations, all verified live 2026-09-07 (HTTP 200 + audio payload; probe results were ephemeral in /tmp, not committed).
- Working recorder pipeline (`recording/`: IcyStreamReader, SongBoundaryDetector, RollingAudioBuffer, MetadataEmbedder).
- Design scratch work in `design/` (glass theme CSS + demo).
- Absences that must not be fabricated: no testimonials, no user base, no press, no pricing, no privacy policy text.
## Product Principles
1. Tune in within seconds — the station list and playback are the front door.
2. Capture is one gesture — saving a song mid-play must never feel like a workflow.
3. The file is the promise — a saved song must carry correct metadata, or the feature has failed.
4. Own the look through tokens — every surface draws from the accent/mode token system, never hardcoded colors.
5. Nothing to sign up for — zero accounts, zero setup, on-device everything.
## Accessibility & Inclusion
AA contrast is the standing bar, already enforced per accent in the token system; touch targets and dynamic font sizes follow Material 3 defaults.

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,9 +143,12 @@ fun LibraryScreen(
TopAppBar(
title = { Text("Library") },
navigationIcon = {
Row(horizontalArrangement = Arrangement.Start) {
AppHomeButton(onClick = onHome)
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
actions = {
Box {

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),
center = Offset(width / 2, centerY),
radius = width / 3,
colorStops = arrayOf(
0f to accentGlow.copy(alpha = 0.06f),
0.55f to accentGlow.copy(alpha = 0.025f),
1f to Color.Transparent,
),
radius = width / 3,
center = Offset(width / 2, centerY),
radius = width * 0.75f,
),
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,6 +846,9 @@ private fun SaveSongButton(
AnimatedContent(
targetState = saveState,
transitionSpec = {
if (reduce)
fadeIn(tween(140)) togetherWith fadeOut(tween(140))
else
scaleIn(initialScale = 0.8f) + fadeIn() togetherWith
scaleOut(targetScale = 0.8f) + fadeOut()
},

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,9 +135,12 @@ fun PlaylistManagerScreen(
TopAppBar(
title = { Text("Playlists") },
navigationIcon = {
Row(horizontalArrangement = Arrangement.Start) {
AppHomeButton(onClick = onHome)
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
actions = {
IconButton(onClick = { showSortMenu = !showSortMenu }) {

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,9 +126,12 @@ fun StationBrowserScreen(
TopAppBar(
title = { Text("Stations") },
navigationIcon = {
Row(horizontalArrangement = Arrangement.Start) {
AppHomeButton(onClick = onHome)
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
actions = {
IconButton(onClick = { showAddDialog = true }) {
@ -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,31 +514,55 @@ 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(
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),
)
}
// Directory stations: offer saving into the local list.
if (onAdd != null) {
IconButton(onClick = onAdd, modifier = Modifier.size(40.dp)) {
Icon(
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 = LocalTokens.current.textSecondary,
tint = tokens.textSecondary,
modifier = Modifier.size(20.dp),
)
}
if (isCustom) {
IconButton(onClick = onDelete) {
IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove",
tint = LocalTokens.current.textSecondary,
tint = tokens.textSecondary,
modifier = Modifier.size(20.dp),
)
}
@ -434,3 +570,52 @@ private fun StationCard(
}
}
}
}
@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,9 +103,12 @@ fun StreamsScreen(
TopAppBar(
title = { Text("Streams") },
navigationIcon = {
Row(horizontalArrangement = Arrangement.Start) {
AppHomeButton(onClick = onHome)
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
actions = {
IconButton(onClick = { showAddDialog = true }) {

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