From bc6396a5703ed4a9188e1c593bd742f022be39f0 Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 00:37:33 -0500 Subject: [PATCH] feat: directory search, favorites, motion system, home button, transport fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 2 + DESIGN.md | 120 +++++++++ PRODUCT.md | 63 +++++ .../data/repository/RadioBrowserRepository.kt | 88 +++++++ .../rada/data/repository/StationRepository.kt | 60 +++++ .../java/com/rada/player/PlaybackManager.kt | 28 +++ .../java/com/rada/recording/StreamMetadata.kt | 15 ++ .../com/rada/ui/components/AppHomeButton.kt | 35 +++ .../java/com/rada/ui/library/LibraryScreen.kt | 9 +- .../java/com/rada/ui/navigation/NavGraph.kt | 48 +++- .../java/com/rada/ui/player/NowPlayingBar.kt | 18 +- .../com/rada/ui/player/NowPlayingViewModel.kt | 1 + .../java/com/rada/ui/player/PlayerScreen.kt | 195 ++++++++++++--- .../com/rada/ui/player/PlayerViewModel.kt | 30 ++- .../ui/playlists/PlaylistManagerScreen.kt | 9 +- .../rada/ui/stations/StationBrowserScreen.kt | 227 ++++++++++++++++-- .../ui/stations/StationBrowserViewModel.kt | 67 +++++- .../java/com/rada/ui/streams/StreamsScreen.kt | 9 +- app/src/main/java/com/rada/ui/theme/Motion.kt | 109 +++++++++ app/src/main/java/com/rada/ui/theme/Theme.kt | 16 +- 20 files changed, 1072 insertions(+), 77 deletions(-) create mode 100644 .gitignore create mode 100644 DESIGN.md create mode 100644 PRODUCT.md create mode 100644 app/src/main/java/com/rada/data/repository/RadioBrowserRepository.kt create mode 100644 app/src/main/java/com/rada/ui/components/AppHomeButton.kt create mode 100644 app/src/main/java/com/rada/ui/theme/Motion.kt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0caff2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Impeccable skill engine binaries (re-downloadable via npx impeccable install) +.hermes/ diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..1ae7f4d --- /dev/null +++ b/DESIGN.md @@ -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. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..e7d25d2 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,63 @@ +# Product + + + +## 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. diff --git a/app/src/main/java/com/rada/data/repository/RadioBrowserRepository.kt b/app/src/main/java/com/rada/data/repository/RadioBrowserRepository.kt new file mode 100644 index 0000000..24990bb --- /dev/null +++ b/app/src/main/java/com/rada/data/repository/RadioBrowserRepository.kt @@ -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 = + 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>(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" + } +} diff --git a/app/src/main/java/com/rada/data/repository/StationRepository.kt b/app/src/main/java/com/rada/data/repository/StationRepository.kt index b7aa160..efee1bf 100644 --- a/app/src/main/java/com/rada/data/repository/StationRepository.kt +++ b/app/src/main/java/com/rada/data/repository/StationRepository.kt @@ -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> = _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 { + 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) { + 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" } } diff --git a/app/src/main/java/com/rada/player/PlaybackManager.kt b/app/src/main/java/com/rada/player/PlaybackManager.kt index fd43b58..30b2d35 100644 --- a/app/src/main/java/com/rada/player/PlaybackManager.kt +++ b/app/src/main/java/com/rada/player/PlaybackManager.kt @@ -74,6 +74,22 @@ class PlaybackManager( private val _isPlayingFlow = MutableStateFlow(false) val isPlayingFlow: StateFlow = _isPlayingFlow.asStateFlow() + /** True while a local library song plays (vs a radio stream). */ + private val _isLocalFlow = MutableStateFlow(false) + val isLocalFlow: StateFlow = _isLocalFlow.asStateFlow() + + /** (title, artist) of the playing local file; null while streaming. */ + private val _localMediaFlow = MutableStateFlow?>(null) + val localMediaFlow: StateFlow?> = _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(null) + val lastStreamMetaFlow: StateFlow = _lastStreamMetaFlow.asStateFlow() + private val _bufferStateFlow = MutableStateFlow(BufferState.IDLE) val bufferStateFlow: StateFlow = _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) } diff --git a/app/src/main/java/com/rada/recording/StreamMetadata.kt b/app/src/main/java/com/rada/recording/StreamMetadata.kt index 66f0606..4097eeb 100644 --- a/app/src/main/java/com/rada/recording/StreamMetadata.kt +++ b/app/src/main/java/com/rada/recording/StreamMetadata.kt @@ -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, diff --git a/app/src/main/java/com/rada/ui/components/AppHomeButton.kt b/app/src/main/java/com/rada/ui/components/AppHomeButton.kt new file mode 100644 index 0000000..d55398d --- /dev/null +++ b/app/src/main/java/com/rada/ui/components/AppHomeButton.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt index 99cdb7f..edf25e3 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt @@ -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 = { diff --git a/app/src/main/java/com/rada/ui/navigation/NavGraph.kt b/app/src/main/java/com/rada/ui/navigation/NavGraph.kt index e91d532..7275e30 100644 --- a/app/src/main/java/com/rada/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/rada/ui/navigation/NavGraph.kt @@ -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)) }, diff --git a/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt b/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt index 5c71363..86aca98 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt @@ -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), ) } diff --git a/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt b/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt index 062d866..cdc996b 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt @@ -15,6 +15,7 @@ class NowPlayingViewModel @Inject constructor( val currentStationUrl: StateFlow = playbackManager.currentStationUrlFlow val isPlaying: StateFlow = playbackManager.isPlayingFlow val bufferState: StateFlow = playbackManager.bufferStateFlow + val isLocal: StateFlow = playbackManager.isLocalFlow fun togglePlayPause() { playbackManager.togglePlayPause() diff --git a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt index 4c31e75..2b92408 100644 --- a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt @@ -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 -> diff --git a/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt b/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt index 4514b73..48e197d 100644 --- a/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt @@ -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 = "", diff --git a/app/src/main/java/com/rada/ui/playlists/PlaylistManagerScreen.kt b/app/src/main/java/com/rada/ui/playlists/PlaylistManagerScreen.kt index 5d72a85..bfa37ae 100644 --- a/app/src/main/java/com/rada/ui/playlists/PlaylistManagerScreen.kt +++ b/app/src/main/java/com/rada/ui/playlists/PlaylistManagerScreen.kt @@ -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 = { diff --git a/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt b/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt index 50f53dc..34bd6cd 100644 --- a/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt +++ b/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt @@ -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(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, + ) + } +} diff --git a/app/src/main/java/com/rada/ui/stations/StationBrowserViewModel.kt b/app/src/main/java/com/rada/ui/stations/StationBrowserViewModel.kt index 6671379..241af48 100644 --- a/app/src/main/java/com/rada/ui/stations/StationBrowserViewModel.kt +++ b/app/src/main/java/com/rada/ui/stations/StationBrowserViewModel.kt @@ -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) : 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 = _searchQuery.asStateFlow() + val favorites: StateFlow> = stationRepository.favorites + + /** URL of whatever is playing now, so the list can mark the active row. */ + val playingUrl: StateFlow = playbackManager.currentStationUrlFlow + val stations: StateFlow> = 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.Idle) + val directoryState: StateFlow = _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() diff --git a/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt b/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt index 72a7232..c9112f0 100644 --- a/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt +++ b/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt @@ -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 = { diff --git a/app/src/main/java/com/rada/ui/theme/Motion.kt b/app/src/main/java/com/rada/ui/theme/Motion.kt new file mode 100644 index 0000000..27e2fe3 --- /dev/null +++ b/app/src/main/java/com/rada/ui/theme/Motion.kt @@ -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 diff --git a/app/src/main/java/com/rada/ui/theme/Theme.kt b/app/src/main/java/com/rada/ui/theme/Theme.kt index d26bc5d..fc6bb4f 100644 --- a/app/src/main/java/com/rada/ui/theme/Theme.kt +++ b/app/src/main/java/com/rada/ui/theme/Theme.kt @@ -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) } }