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:
parent
1b7c6febbb
commit
bc6396a570
20 changed files with 1068 additions and 73 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Impeccable skill engine binaries (re-downloadable via npx impeccable install)
|
||||||
|
.hermes/
|
||||||
120
DESIGN.md
Normal file
120
DESIGN.md
Normal 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
63
PRODUCT.md
Normal 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.
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,9 @@ import android.content.SharedPreferences
|
||||||
import com.rada.data.local.DefaultStations
|
import com.rada.data.local.DefaultStations
|
||||||
import com.rada.domain.model.Station
|
import com.rada.domain.model.Station
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
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.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -61,6 +64,62 @@ class StationRepository @Inject constructor(
|
||||||
|
|
||||||
fun isRenamed(id: String): Boolean = nameOverrides.containsKey(id)
|
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? =
|
fun getOriginalStationName(id: String): String? =
|
||||||
DefaultStations.getById(id)?.name ?: loadCustomStations().find { it.id == id }?.name
|
DefaultStations.getById(id)?.name ?: loadCustomStations().find { it.id == id }?.name
|
||||||
|
|
||||||
|
|
@ -127,5 +186,6 @@ class StationRepository @Inject constructor(
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_CUSTOM_STATIONS = "custom_stations"
|
private const val KEY_CUSTOM_STATIONS = "custom_stations"
|
||||||
private const val KEY_NAME_OVERRIDES = "station_name_overrides"
|
private const val KEY_NAME_OVERRIDES = "station_name_overrides"
|
||||||
|
private const val KEY_FAVORITE_STATIONS = "favorite_stations"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,22 @@ class PlaybackManager(
|
||||||
private val _isPlayingFlow = MutableStateFlow(false)
|
private val _isPlayingFlow = MutableStateFlow(false)
|
||||||
val isPlayingFlow: StateFlow<Boolean> = _isPlayingFlow.asStateFlow()
|
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)
|
private val _bufferStateFlow = MutableStateFlow(BufferState.IDLE)
|
||||||
val bufferStateFlow: StateFlow<BufferState> = _bufferStateFlow.asStateFlow()
|
val bufferStateFlow: StateFlow<BufferState> = _bufferStateFlow.asStateFlow()
|
||||||
|
|
||||||
|
|
@ -125,6 +141,9 @@ class PlaybackManager(
|
||||||
currentStationName = name
|
currentStationName = name
|
||||||
_currentStationUrlFlow.value = url
|
_currentStationUrlFlow.value = url
|
||||||
_currentStationNameFlow.value = name
|
_currentStationNameFlow.value = name
|
||||||
|
_isLocalFlow.value = false
|
||||||
|
_localMediaFlow.value = null
|
||||||
|
_lastStreamMetaFlow.value = null
|
||||||
val ctrl = controller ?: return
|
val ctrl = controller ?: return
|
||||||
|
|
||||||
stopAll()
|
stopAll()
|
||||||
|
|
@ -152,6 +171,12 @@ class PlaybackManager(
|
||||||
fun playLocalFile(filePath: String, title: String, artist: String) {
|
fun playLocalFile(filePath: String, title: String, artist: String) {
|
||||||
currentStationUrl = null
|
currentStationUrl = null
|
||||||
currentStationName = ""
|
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
|
val ctrl = controller ?: return
|
||||||
|
|
||||||
stopAll()
|
stopAll()
|
||||||
|
|
@ -258,6 +283,9 @@ class PlaybackManager(
|
||||||
// not an absolute one — that mismatch is what corrupted
|
// not an absolute one — that mismatch is what corrupted
|
||||||
// boundaries after the buffer wrapped.
|
// boundaries after the buffer wrapped.
|
||||||
metadataTimeline.record(metadata, audioBytesAtMetadata)
|
metadataTimeline.record(metadata, audioBytesAtMetadata)
|
||||||
|
if (metadata.artist.isNotEmpty() || metadata.title.isNotEmpty()) {
|
||||||
|
_lastStreamMetaFlow.value = metadata
|
||||||
|
}
|
||||||
listener?.onIcyMetadata(metadata)
|
listener?.onIcyMetadata(metadata)
|
||||||
listener?.onMetadataUpdate(metadata.artist, metadata.title)
|
listener?.onMetadataUpdate(metadata.artist, metadata.title)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,19 @@ data class StreamMetadata(
|
||||||
get() = if (artist.isNotEmpty() && title.isNotEmpty()) title else ""
|
get() = if (artist.isNotEmpty() && title.isNotEmpty()) title else ""
|
||||||
|
|
||||||
companion object {
|
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 {
|
fun parseStreamTitle(raw: String): StreamMetadata {
|
||||||
val cleaned = raw.trim().removeSuffix(";")
|
val cleaned = raw.trim().removeSuffix(";")
|
||||||
var artist = ""
|
var artist = ""
|
||||||
|
|
@ -31,6 +44,8 @@ data class StreamMetadata(
|
||||||
title = value
|
title = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isPlaceholder(artist)) artist = ""
|
||||||
|
if (isPlaceholder(title)) title = ""
|
||||||
|
|
||||||
return StreamMetadata(
|
return StreamMetadata(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
|
|
|
||||||
35
app/src/main/java/com/rada/ui/components/AppHomeButton.kt
Normal file
35
app/src/main/java/com/rada/ui/components/AppHomeButton.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -77,12 +77,14 @@ import com.rada.data.local.entity.PlaylistEntity
|
||||||
import com.rada.data.local.entity.SavedSongEntity
|
import com.rada.data.local.entity.SavedSongEntity
|
||||||
import com.rada.data.repository.SortOrder
|
import com.rada.data.repository.SortOrder
|
||||||
import com.rada.ui.theme.AppearanceDialog
|
import com.rada.ui.theme.AppearanceDialog
|
||||||
|
import com.rada.ui.components.AppHomeButton
|
||||||
import com.rada.ui.theme.LocalTokens
|
import com.rada.ui.theme.LocalTokens
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryScreen(
|
fun LibraryScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onHome: () -> Unit = {},
|
||||||
initialPlaylistId: Long? = null,
|
initialPlaylistId: Long? = null,
|
||||||
viewModel: LibraryViewModel = hiltViewModel(),
|
viewModel: LibraryViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -141,9 +143,12 @@ fun LibraryScreen(
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Library") },
|
title = { Text("Library") },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
|
Row(horizontalArrangement = Arrangement.Start) {
|
||||||
|
AppHomeButton(onClick = onHome)
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
Box {
|
Box {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,13 @@ import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.navArgument
|
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.library.LibraryScreen
|
||||||
import com.rada.ui.player.NowPlayingBar
|
import com.rada.ui.player.NowPlayingBar
|
||||||
import com.rada.ui.player.PlayerScreen
|
import com.rada.ui.player.PlayerScreen
|
||||||
|
|
@ -24,6 +31,7 @@ fun NavGraph(navController: NavHostController) {
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
val showNowPlayingBar = currentRoute != Screen.Player.route
|
val showNowPlayingBar = currentRoute != Screen.Player.route
|
||||||
|
val reduce = LocalReduceMotion.current
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
NavHost(
|
NavHost(
|
||||||
|
|
@ -31,7 +39,13 @@ fun NavGraph(navController: NavHostController) {
|
||||||
startDestination = Screen.Player.route,
|
startDestination = Screen.Player.route,
|
||||||
modifier = Modifier.weight(1f),
|
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(
|
PlayerScreen(
|
||||||
onNavigateToStations = { navController.navigate(Screen.Stations.route) },
|
onNavigateToStations = { navController.navigate(Screen.Stations.route) },
|
||||||
onNavigateToLibrary = { navController.navigate(Screen.Library.createRoute()) },
|
onNavigateToLibrary = { navController.navigate(Screen.Library.createRoute()) },
|
||||||
|
|
@ -47,28 +61,54 @@ fun NavGraph(navController: NavHostController) {
|
||||||
defaultValue = -1L
|
defaultValue = -1L
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
enterTransition = { detailEnter(reduce) },
|
||||||
|
exitTransition = { detailExit(reduce) },
|
||||||
|
popEnterTransition = { detailPopEnter(reduce) },
|
||||||
|
popExitTransition = { detailPopExit(reduce) },
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val playlistId = backStackEntry.arguments?.getLong("playlistId")?.takeIf { it > 0 }
|
val playlistId = backStackEntry.arguments?.getLong("playlistId")?.takeIf { it > 0 }
|
||||||
LibraryScreen(
|
LibraryScreen(
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
|
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
|
||||||
initialPlaylistId = playlistId,
|
initialPlaylistId = playlistId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.Stations.route) {
|
composable(
|
||||||
|
Screen.Stations.route,
|
||||||
|
enterTransition = { detailEnter(reduce) },
|
||||||
|
exitTransition = { detailExit(reduce) },
|
||||||
|
popEnterTransition = { detailPopEnter(reduce) },
|
||||||
|
popExitTransition = { detailPopExit(reduce) },
|
||||||
|
) {
|
||||||
StationBrowserScreen(
|
StationBrowserScreen(
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
|
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
|
||||||
onStationSelected = { navController.popBackStack() },
|
onStationSelected = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.Streams.route) {
|
composable(
|
||||||
|
Screen.Streams.route,
|
||||||
|
enterTransition = { detailEnter(reduce) },
|
||||||
|
exitTransition = { detailExit(reduce) },
|
||||||
|
popEnterTransition = { detailPopEnter(reduce) },
|
||||||
|
popExitTransition = { detailPopExit(reduce) },
|
||||||
|
) {
|
||||||
StreamsScreen(
|
StreamsScreen(
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
|
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
|
||||||
onStreamSelected = { navController.popBackStack() },
|
onStreamSelected = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.Playlists.route) {
|
composable(
|
||||||
|
Screen.Playlists.route,
|
||||||
|
enterTransition = { detailEnter(reduce) },
|
||||||
|
exitTransition = { detailExit(reduce) },
|
||||||
|
popEnterTransition = { detailPopEnter(reduce) },
|
||||||
|
popExitTransition = { detailPopExit(reduce) },
|
||||||
|
) {
|
||||||
PlaylistManagerScreen(
|
PlaylistManagerScreen(
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
|
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
|
||||||
onPlaylistSelected = { playlistId ->
|
onPlaylistSelected = { playlistId ->
|
||||||
navController.navigate(Screen.Library.createRoute(playlistId))
|
navController.navigate(Screen.Library.createRoute(playlistId))
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
package com.rada.ui.player
|
package com.rada.ui.player
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.slideInVertically
|
import com.rada.ui.theme.LocalReduceMotion
|
||||||
import androidx.compose.animation.slideOutVertically
|
import com.rada.ui.theme.barEnter
|
||||||
|
import com.rada.ui.theme.barExit
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
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.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
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.Radio
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
|
@ -49,16 +51,18 @@ fun NowPlayingBar(
|
||||||
viewModel: NowPlayingViewModel = hiltViewModel(),
|
viewModel: NowPlayingViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val tokens = LocalTokens.current
|
val tokens = LocalTokens.current
|
||||||
|
val reduce = LocalReduceMotion.current
|
||||||
|
|
||||||
val stationName by viewModel.currentStationName.collectAsState()
|
val stationName by viewModel.currentStationName.collectAsState()
|
||||||
val isPlaying by viewModel.isPlaying.collectAsState()
|
val isPlaying by viewModel.isPlaying.collectAsState()
|
||||||
val bufferState by viewModel.bufferState.collectAsState()
|
val bufferState by viewModel.bufferState.collectAsState()
|
||||||
|
val isLocal by viewModel.isLocal.collectAsState()
|
||||||
val isVisible = stationName.isNotEmpty()
|
val isVisible = stationName.isNotEmpty()
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isVisible,
|
visible = isVisible,
|
||||||
enter = slideInVertically(initialOffsetY = { it }),
|
enter = barEnter(reduce),
|
||||||
exit = slideOutVertically(targetOffsetY = { it }),
|
exit = barExit(reduce),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -104,9 +108,9 @@ fun NowPlayingBar(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Radio,
|
if (isLocal) Icons.Default.MusicNote else Icons.Default.Radio,
|
||||||
contentDescription = null,
|
contentDescription = if (isLocal) "Now playing" else "Now playing station",
|
||||||
tint = Color.White,
|
tint = LocalTokens.current.accentOnFill,
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ class NowPlayingViewModel @Inject constructor(
|
||||||
val currentStationUrl: StateFlow<String?> = playbackManager.currentStationUrlFlow
|
val currentStationUrl: StateFlow<String?> = playbackManager.currentStationUrlFlow
|
||||||
val isPlaying: StateFlow<Boolean> = playbackManager.isPlayingFlow
|
val isPlaying: StateFlow<Boolean> = playbackManager.isPlayingFlow
|
||||||
val bufferState: StateFlow<PlaybackManager.BufferState> = playbackManager.bufferStateFlow
|
val bufferState: StateFlow<PlaybackManager.BufferState> = playbackManager.bufferStateFlow
|
||||||
|
val isLocal: StateFlow<Boolean> = playbackManager.isLocalFlow
|
||||||
|
|
||||||
fun togglePlayPause() {
|
fun togglePlayPause() {
|
||||||
playbackManager.togglePlayPause()
|
playbackManager.togglePlayPause()
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,13 @@ import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.togetherWith
|
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.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Check
|
||||||
import androidx.compose.material.icons.filled.Error
|
import androidx.compose.material.icons.filled.Error
|
||||||
import androidx.compose.material.icons.filled.LibraryMusic
|
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.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
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.Radio
|
||||||
import androidx.compose.material.icons.filled.Save
|
import androidx.compose.material.icons.filled.Save
|
||||||
import androidx.compose.material.icons.filled.Search
|
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.SkipNext
|
||||||
import androidx.compose.material.icons.filled.SkipPrevious
|
import androidx.compose.material.icons.filled.SkipPrevious
|
||||||
import androidx.compose.material.icons.filled.Stop
|
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.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
|
@ -89,7 +99,9 @@ import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.rada.domain.model.Station
|
import com.rada.domain.model.Station
|
||||||
import com.rada.player.PlaybackManager
|
import com.rada.player.PlaybackManager
|
||||||
|
import com.rada.ui.components.AppHomeButton
|
||||||
import com.rada.ui.theme.LocalTokens
|
import com.rada.ui.theme.LocalTokens
|
||||||
|
import com.rada.ui.theme.AppearanceDialog
|
||||||
import com.rada.ui.theme.GradientEnd
|
import com.rada.ui.theme.GradientEnd
|
||||||
import com.rada.ui.theme.GradientMid
|
import com.rada.ui.theme.GradientMid
|
||||||
import com.rada.ui.theme.PremiumCyan
|
import com.rada.ui.theme.PremiumCyan
|
||||||
|
|
@ -107,6 +119,7 @@ fun PlayerScreen(
|
||||||
val importMessage by viewModel.importMessage.collectAsState()
|
val importMessage by viewModel.importMessage.collectAsState()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var showImportDialog by remember { mutableStateOf(false) }
|
var showImportDialog by remember { mutableStateOf(false) }
|
||||||
|
var showAppearance by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val importSongsPicker = rememberLauncherForActivityResult(
|
val importSongsPicker = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.OpenMultipleDocuments()
|
ActivityResultContracts.OpenMultipleDocuments()
|
||||||
|
|
@ -183,9 +196,42 @@ fun PlayerScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showAppearance) {
|
||||||
|
AppearanceDialog(onDismiss = { showAppearance = false })
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
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 = {
|
bottomBar = {
|
||||||
BottomAppBar(
|
BottomAppBar(
|
||||||
containerColor = LocalTokens.current.panel.copy(alpha = 0.94f),
|
containerColor = LocalTokens.current.panel.copy(alpha = 0.94f),
|
||||||
|
|
@ -238,7 +284,7 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
if (uiState.currentStation != null) {
|
if (uiState.currentStation != null || uiState.isLocal) {
|
||||||
NowPlayingView(
|
NowPlayingView(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
viewModel = viewModel,
|
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(
|
drawCircle(
|
||||||
brush = Brush.radialGradient(
|
brush = Brush.radialGradient(
|
||||||
colors = listOf(accentGlow.copy(alpha = 0.15f), Color.Transparent),
|
colorStops = arrayOf(
|
||||||
center = Offset(width / 2, centerY),
|
0f to accentGlow.copy(alpha = 0.06f),
|
||||||
radius = width / 3,
|
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),
|
center = Offset(width / 2, centerY),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -407,6 +457,7 @@ private fun NowPlayingView(
|
||||||
viewModel: PlayerViewModel,
|
viewModel: PlayerViewModel,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val reduce = LocalReduceMotion.current
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -422,13 +473,64 @@ private fun NowPlayingView(
|
||||||
bufferState = uiState.bufferState,
|
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))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
// Artist/Station name
|
// Artist/Station name
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = uiState.artist,
|
targetState = uiState.artist,
|
||||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
|
||||||
label = "artist",
|
label = "artist",
|
||||||
) { artist ->
|
) { artist ->
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -444,7 +546,7 @@ private fun NowPlayingView(
|
||||||
// Song title
|
// Song title
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = uiState.title,
|
targetState = uiState.title,
|
||||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
|
||||||
label = "title",
|
label = "title",
|
||||||
) { title ->
|
) { title ->
|
||||||
if (title.isNotEmpty()) {
|
if (title.isNotEmpty()) {
|
||||||
|
|
@ -459,7 +561,7 @@ private fun NowPlayingView(
|
||||||
)
|
)
|
||||||
} else if (uiState.isPlaying && uiState.currentStation != null) {
|
} else if (uiState.isPlaying && uiState.currentStation != null) {
|
||||||
Text(
|
Text(
|
||||||
text = if (uiState.hasMetadata) "" else "No metadata",
|
text = if (uiState.hasMetadata) "" else "No track info from this station",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
|
@ -479,14 +581,27 @@ private fun NowPlayingView(
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
// Playback controls
|
// Playback controls — conventional transport order: stop · prev · play · next
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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(
|
IconButton(
|
||||||
onClick = { viewModel.previousStation() },
|
onClick = { viewModel.previousStation() },
|
||||||
enabled = uiState.currentStation != null,
|
enabled = uiState.currentStation != null || uiState.isLocal,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.SkipPrevious,
|
Icons.Default.SkipPrevious,
|
||||||
|
|
@ -497,42 +612,48 @@ private fun NowPlayingView(
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
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(
|
FilledIconButton(
|
||||||
onClick = { viewModel.togglePlayPause() },
|
onClick = { viewModel.togglePlayPause() },
|
||||||
enabled = uiState.currentStation != null,
|
enabled = uiState.currentStation != null || uiState.isLocal,
|
||||||
modifier = Modifier.size(72.dp),
|
modifier = Modifier.size(72.dp),
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
colors = IconButtonDefaults.filledIconButtonColors(
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
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(
|
Icon(
|
||||||
Icons.Default.Pause,
|
if (playing) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||||
contentDescription = "Pause",
|
contentDescription = if (playing) "Pause" else "Play",
|
||||||
modifier = Modifier.size(36.dp),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.PlayArrow,
|
|
||||||
contentDescription = "Play",
|
|
||||||
modifier = Modifier.size(36.dp),
|
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,
|
saveState: PlaybackManager.SaveState,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val reduce = LocalReduceMotion.current
|
||||||
val containerColor = when {
|
val containerColor = when {
|
||||||
saveState == PlaybackManager.SaveState.SAVING -> MaterialTheme.colorScheme.secondary
|
saveState == PlaybackManager.SaveState.SAVING -> MaterialTheme.colorScheme.secondary
|
||||||
saveState == PlaybackManager.SaveState.SUCCESS -> Color(0xFF10B981)
|
saveState == PlaybackManager.SaveState.SUCCESS -> Color(0xFF10B981)
|
||||||
|
|
@ -724,6 +846,9 @@ private fun SaveSongButton(
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = saveState,
|
targetState = saveState,
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
|
if (reduce)
|
||||||
|
fadeIn(tween(140)) togetherWith fadeOut(tween(140))
|
||||||
|
else
|
||||||
scaleIn(initialScale = 0.8f) + fadeIn() togetherWith
|
scaleIn(initialScale = 0.8f) + fadeIn() togetherWith
|
||||||
scaleOut(targetScale = 0.8f) + fadeOut()
|
scaleOut(targetScale = 0.8f) + fadeOut()
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -139,9 +139,27 @@ class PlayerViewModel @Inject constructor(
|
||||||
currentStation = playbackManager.currentStationUrl?.let { url ->
|
currentStation = playbackManager.currentStationUrl?.let { url ->
|
||||||
Station(id = url, name = playbackManager.currentStationName, url = 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,
|
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) {
|
fun playStation(station: Station) {
|
||||||
|
|
@ -255,16 +273,23 @@ class PlayerViewModel @Inject constructor(
|
||||||
fun syncPlaybackState() {
|
fun syncPlaybackState() {
|
||||||
playbackManager.listener = playbackListener
|
playbackManager.listener = playbackListener
|
||||||
val url = playbackManager.currentStationUrl
|
val url = playbackManager.currentStationUrl
|
||||||
|
val isLocal = playbackManager.isLocalFlow.value
|
||||||
|
val local = playbackManager.localMediaFlow.value
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
stations = stationRepository.getAll(),
|
stations = stationRepository.getAll(),
|
||||||
currentStation = url?.let { u ->
|
currentStation = url?.let { u ->
|
||||||
Station(id = u, name = playbackManager.currentStationName, url = u)
|
Station(id = u, name = playbackManager.currentStationName, url = u)
|
||||||
},
|
},
|
||||||
|
isLocal = isLocal,
|
||||||
|
localTitle = local?.first.orEmpty(),
|
||||||
|
localArtist = local?.second.orEmpty(),
|
||||||
isPlaying = playbackManager.isPlaying,
|
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() {
|
override fun onCleared() {
|
||||||
|
|
@ -275,6 +300,9 @@ class PlayerViewModel @Inject constructor(
|
||||||
|
|
||||||
data class PlayerUiState(
|
data class PlayerUiState(
|
||||||
val currentStation: Station? = null,
|
val currentStation: Station? = null,
|
||||||
|
val isLocal: Boolean = false,
|
||||||
|
val localTitle: String = "",
|
||||||
|
val localArtist: String = "",
|
||||||
val isPlaying: Boolean = false,
|
val isPlaying: Boolean = false,
|
||||||
val artist: String = "",
|
val artist: String = "",
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
|
|
|
||||||
|
|
@ -57,12 +57,14 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.rada.data.local.entity.PlaylistEntity
|
import com.rada.data.local.entity.PlaylistEntity
|
||||||
|
import com.rada.ui.components.AppHomeButton
|
||||||
import com.rada.ui.theme.LocalTokens
|
import com.rada.ui.theme.LocalTokens
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaylistManagerScreen(
|
fun PlaylistManagerScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onHome: () -> Unit = {},
|
||||||
onPlaylistSelected: (Long) -> Unit = {},
|
onPlaylistSelected: (Long) -> Unit = {},
|
||||||
viewModel: PlaylistManagerViewModel = hiltViewModel(),
|
viewModel: PlaylistManagerViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -133,9 +135,12 @@ fun PlaylistManagerScreen(
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Playlists") },
|
title = { Text("Playlists") },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
|
Row(horizontalArrangement = Arrangement.Start) {
|
||||||
|
AppHomeButton(onClick = onHome)
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { showSortMenu = !showSortMenu }) {
|
IconButton(onClick = { showSortMenu = !showSortMenu }) {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Radio
|
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.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
|
@ -49,9 +54,11 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.rada.domain.model.Station
|
import com.rada.domain.model.Station
|
||||||
|
import com.rada.ui.components.AppHomeButton
|
||||||
import com.rada.ui.theme.LocalTokens
|
import com.rada.ui.theme.LocalTokens
|
||||||
|
|
||||||
private val genreColors = mapOf(
|
private val genreColors = mapOf(
|
||||||
|
|
@ -75,11 +82,15 @@ private val genreColors = mapOf(
|
||||||
@Composable
|
@Composable
|
||||||
fun StationBrowserScreen(
|
fun StationBrowserScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onHome: () -> Unit = {},
|
||||||
onStationSelected: () -> Unit = {},
|
onStationSelected: () -> Unit = {},
|
||||||
viewModel: StationBrowserViewModel = hiltViewModel(),
|
viewModel: StationBrowserViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val stationsByGenre by viewModel.stationsByGenre.collectAsState()
|
val stationsByGenre by viewModel.stationsByGenre.collectAsState()
|
||||||
val searchQuery by viewModel.searchQuery.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 showAddDialog by remember { mutableStateOf(false) }
|
||||||
var renameTarget by remember { mutableStateOf<Station?>(null) }
|
var renameTarget by remember { mutableStateOf<Station?>(null) }
|
||||||
|
|
||||||
|
|
@ -115,9 +126,12 @@ fun StationBrowserScreen(
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Stations") },
|
title = { Text("Stations") },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
|
Row(horizontalArrangement = Arrangement.Start) {
|
||||||
|
AppHomeButton(onClick = onHome)
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { showAddDialog = true }) {
|
IconButton(onClick = { showAddDialog = true }) {
|
||||||
|
|
@ -162,6 +176,27 @@ fun StationBrowserScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
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) ->
|
stationsByGenre.forEach { (genre, stations) ->
|
||||||
item {
|
item {
|
||||||
GenreHeader(genre = genre, count = stations.size)
|
GenreHeader(genre = genre, count = stations.size)
|
||||||
|
|
@ -170,10 +205,13 @@ fun StationBrowserScreen(
|
||||||
StationCard(
|
StationCard(
|
||||||
station = station,
|
station = station,
|
||||||
isCustom = viewModel.isCustomStation(station.id),
|
isCustom = viewModel.isCustomStation(station.id),
|
||||||
|
isFavorite = viewModel.isFavorite(station.id),
|
||||||
|
isPlaying = playingUrl == station.url,
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.selectStation(station)
|
viewModel.selectStation(station)
|
||||||
onStationSelected()
|
onStationSelected()
|
||||||
},
|
},
|
||||||
|
onToggleFavorite = { viewModel.toggleFavorite(station) },
|
||||||
onRename = { renameTarget = station },
|
onRename = { renameTarget = station },
|
||||||
onDelete = {
|
onDelete = {
|
||||||
viewModel.removeCustomStation(station.id)
|
viewModel.removeCustomStation(station.id)
|
||||||
|
|
@ -184,6 +222,72 @@ fun StationBrowserScreen(
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
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(
|
private fun StationCard(
|
||||||
station: Station,
|
station: Station,
|
||||||
isCustom: Boolean,
|
isCustom: Boolean,
|
||||||
|
isFavorite: Boolean,
|
||||||
|
isPlaying: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onToggleFavorite: () -> Unit,
|
||||||
onRename: () -> Unit,
|
onRename: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
|
onAdd: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
val tokens = LocalTokens.current
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp)
|
.padding(horizontal = 12.dp)
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
colors = CardDefaults.cardColors(
|
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),
|
shape = RoundedCornerShape(12.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(LocalTokens.current.accentFill.copy(alpha = 0.15f)),
|
.background(tokens.accentFill.copy(alpha = 0.15f)),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Radio,
|
if (isPlaying) Icons.Default.PlayArrow else Icons.Default.Radio,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = LocalTokens.current.accentText,
|
tint = tokens.accentText,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -402,31 +514,55 @@ private fun StationCard(
|
||||||
Text(
|
Text(
|
||||||
text = station.name,
|
text = station.name,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = if (isPlaying) FontWeight.Bold else FontWeight.SemiBold,
|
||||||
color = LocalTokens.current.text,
|
color = tokens.text,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
if (station.genre.isNotEmpty()) {
|
val subtitle = station.description.ifBlank { station.genre }
|
||||||
|
if (subtitle.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = station.genre,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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(
|
Icon(
|
||||||
Icons.Default.Edit,
|
Icons.Default.Edit,
|
||||||
contentDescription = "Rename",
|
contentDescription = "Rename",
|
||||||
tint = LocalTokens.current.textSecondary,
|
tint = tokens.textSecondary,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isCustom) {
|
if (isCustom) {
|
||||||
IconButton(onClick = onDelete) {
|
IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Delete,
|
Icons.Default.Delete,
|
||||||
contentDescription = "Remove",
|
contentDescription = "Remove",
|
||||||
tint = LocalTokens.current.textSecondary,
|
tint = tokens.textSecondary,
|
||||||
modifier = Modifier.size(20.dp),
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,37 @@ package com.rada.ui.stations
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.rada.data.repository.RadioBrowserRepository
|
||||||
import com.rada.data.repository.StationRepository
|
import com.rada.data.repository.StationRepository
|
||||||
import com.rada.domain.model.Station
|
import com.rada.domain.model.Station
|
||||||
import com.rada.player.PlaybackManager
|
import com.rada.player.PlaybackManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
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.SharingStarted
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import javax.inject.Inject
|
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
|
@HiltViewModel
|
||||||
class StationBrowserViewModel @Inject constructor(
|
class StationBrowserViewModel @Inject constructor(
|
||||||
private val stationRepository: StationRepository,
|
private val stationRepository: StationRepository,
|
||||||
|
private val radioBrowserRepository: RadioBrowserRepository,
|
||||||
private val playbackManager: PlaybackManager,
|
private val playbackManager: PlaybackManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|
@ -26,6 +41,11 @@ class StationBrowserViewModel @Inject constructor(
|
||||||
private val _searchQuery = MutableStateFlow("")
|
private val _searchQuery = MutableStateFlow("")
|
||||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
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 stations: StateFlow<List<Station>> = combine(_allStations, _searchQuery) { list, query ->
|
||||||
val filtered = if (query.isBlank()) list
|
val filtered = if (query.isBlank()) list
|
||||||
else list.filter {
|
else list.filter {
|
||||||
|
|
@ -48,6 +68,33 @@ class StationBrowserViewModel @Inject constructor(
|
||||||
.toSortedMap(compareBy { it.lowercase() })
|
.toSortedMap(compareBy { it.lowercase() })
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
|
}.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) {
|
fun search(query: String) {
|
||||||
_searchQuery.value = query
|
_searchQuery.value = query
|
||||||
}
|
}
|
||||||
|
|
@ -56,6 +103,15 @@ class StationBrowserViewModel @Inject constructor(
|
||||||
playbackManager.playStation(station.url, station.name)
|
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) {
|
fun addCustomStation(name: String, url: String) {
|
||||||
if (name.isBlank() || url.isBlank()) return
|
if (name.isBlank() || url.isBlank()) return
|
||||||
val id = "custom-${System.currentTimeMillis()}"
|
val id = "custom-${System.currentTimeMillis()}"
|
||||||
|
|
@ -69,6 +125,13 @@ class StationBrowserViewModel @Inject constructor(
|
||||||
_allStations.value = stationRepository.getAll()
|
_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) {
|
fun removeCustomStation(id: String) {
|
||||||
stationRepository.removeStation(id)
|
stationRepository.removeStation(id)
|
||||||
_allStations.value = stationRepository.getAll()
|
_allStations.value = stationRepository.getAll()
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,14 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.rada.domain.model.StreamEntry
|
import com.rada.domain.model.StreamEntry
|
||||||
|
import com.rada.ui.components.AppHomeButton
|
||||||
import com.rada.ui.theme.LocalTokens
|
import com.rada.ui.theme.LocalTokens
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun StreamsScreen(
|
fun StreamsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onHome: () -> Unit = {},
|
||||||
onStreamSelected: () -> Unit = {},
|
onStreamSelected: () -> Unit = {},
|
||||||
viewModel: StreamsViewModel = hiltViewModel(),
|
viewModel: StreamsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -101,9 +103,12 @@ fun StreamsScreen(
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Streams") },
|
title = { Text("Streams") },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
|
Row(horizontalArrangement = Arrangement.Start) {
|
||||||
|
AppHomeButton(onClick = onHome)
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { showAddDialog = true }) {
|
IconButton(onClick = { showAddDialog = true }) {
|
||||||
|
|
|
||||||
109
app/src/main/java/com/rada/ui/theme/Motion.kt
Normal file
109
app/src/main/java/com/rada/ui/theme/Motion.kt
Normal 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
|
||||||
|
|
@ -10,12 +10,14 @@ import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import android.provider.Settings
|
||||||
|
|
||||||
val LocalTokens = staticCompositionLocalOf { darkTokens(AccentMode.VIOLET) }
|
val LocalTokens = staticCompositionLocalOf { darkTokens(AccentMode.VIOLET) }
|
||||||
|
|
||||||
|
|
@ -24,6 +26,10 @@ fun SatsAmpTheme(
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
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) {
|
val dark = when (UiPrefs.mode) {
|
||||||
AppMode.DARK -> true
|
AppMode.DARK -> true
|
||||||
AppMode.LIGHT -> false
|
AppMode.LIGHT -> false
|
||||||
|
|
@ -53,7 +59,15 @@ fun SatsAmpTheme(
|
||||||
surfaceTint = tok.accentFill,
|
surfaceTint = tok.accentFill,
|
||||||
)
|
)
|
||||||
val colorScheme = if (dark) buildDark(schemeArgs) else buildLight(schemeArgs)
|
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)
|
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue