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
|
|
@ -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.domain.model.Station
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import javax.inject.Inject
|
||||
|
|
@ -61,6 +64,62 @@ class StationRepository @Inject constructor(
|
|||
|
||||
fun isRenamed(id: String): Boolean = nameOverrides.containsKey(id)
|
||||
|
||||
// ── Favorites ──────────────────────────────────────────────────────
|
||||
// A favorite stores the full Station so directory stations (which are
|
||||
// not part of the built-in list) survive the toggle and show offline.
|
||||
|
||||
private val _favorites = MutableStateFlow(loadFavorites())
|
||||
val favorites: StateFlow<List<Station>> = _favorites.asStateFlow()
|
||||
|
||||
fun isFavorite(id: String): Boolean = _favorites.value.any { it.id == id }
|
||||
|
||||
fun toggleFavorite(station: Station) {
|
||||
_favorites.value = if (_favorites.value.any { it.id == station.id }) {
|
||||
_favorites.value.filterNot { it.id == station.id }
|
||||
} else {
|
||||
_favorites.value + station
|
||||
}
|
||||
saveFavorites(_favorites.value)
|
||||
}
|
||||
|
||||
private fun loadFavorites(): List<Station> {
|
||||
val json = prefs.getString(KEY_FAVORITE_STATIONS, null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val obj = arr.optJSONObject(i) ?: return@mapNotNull null
|
||||
Station(
|
||||
id = obj.getString("id"),
|
||||
name = obj.getString("name"),
|
||||
url = obj.getString("url"),
|
||||
genre = obj.optString("genre", ""),
|
||||
description = obj.optString("description", ""),
|
||||
logoUrl = obj.optString("logoUrl", ""),
|
||||
country = obj.optString("country", ""),
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveFavorites(list: List<Station>) {
|
||||
val arr = JSONArray()
|
||||
list.forEach { s ->
|
||||
arr.put(
|
||||
JSONObject()
|
||||
.put("id", s.id)
|
||||
.put("name", s.name)
|
||||
.put("url", s.url)
|
||||
.put("genre", s.genre)
|
||||
.put("description", s.description)
|
||||
.put("logoUrl", s.logoUrl)
|
||||
.put("country", s.country),
|
||||
)
|
||||
}
|
||||
prefs.edit().putString(KEY_FAVORITE_STATIONS, arr.toString()).apply()
|
||||
}
|
||||
|
||||
fun getOriginalStationName(id: String): String? =
|
||||
DefaultStations.getById(id)?.name ?: loadCustomStations().find { it.id == id }?.name
|
||||
|
||||
|
|
@ -127,5 +186,6 @@ class StationRepository @Inject constructor(
|
|||
companion object {
|
||||
private const val KEY_CUSTOM_STATIONS = "custom_stations"
|
||||
private const val KEY_NAME_OVERRIDES = "station_name_overrides"
|
||||
private const val KEY_FAVORITE_STATIONS = "favorite_stations"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,22 @@ class PlaybackManager(
|
|||
private val _isPlayingFlow = MutableStateFlow(false)
|
||||
val isPlayingFlow: StateFlow<Boolean> = _isPlayingFlow.asStateFlow()
|
||||
|
||||
/** True while a local library song plays (vs a radio stream). */
|
||||
private val _isLocalFlow = MutableStateFlow(false)
|
||||
val isLocalFlow: StateFlow<Boolean> = _isLocalFlow.asStateFlow()
|
||||
|
||||
/** (title, artist) of the playing local file; null while streaming. */
|
||||
private val _localMediaFlow = MutableStateFlow<Pair<String, String>?>(null)
|
||||
val localMediaFlow: StateFlow<Pair<String, String>?> = _localMediaFlow.asStateFlow()
|
||||
|
||||
/**
|
||||
* Last non-empty ICY metadata for the current stream, cached so screens
|
||||
* that (re)subscribe after the metadata event (player reopened mid-song,
|
||||
* a stream that sends metadata only once at connect) still show the track.
|
||||
*/
|
||||
private val _lastStreamMetaFlow = MutableStateFlow<StreamMetadata?>(null)
|
||||
val lastStreamMetaFlow: StateFlow<StreamMetadata?> = _lastStreamMetaFlow.asStateFlow()
|
||||
|
||||
private val _bufferStateFlow = MutableStateFlow(BufferState.IDLE)
|
||||
val bufferStateFlow: StateFlow<BufferState> = _bufferStateFlow.asStateFlow()
|
||||
|
||||
|
|
@ -125,6 +141,9 @@ class PlaybackManager(
|
|||
currentStationName = name
|
||||
_currentStationUrlFlow.value = url
|
||||
_currentStationNameFlow.value = name
|
||||
_isLocalFlow.value = false
|
||||
_localMediaFlow.value = null
|
||||
_lastStreamMetaFlow.value = null
|
||||
val ctrl = controller ?: return
|
||||
|
||||
stopAll()
|
||||
|
|
@ -152,6 +171,12 @@ class PlaybackManager(
|
|||
fun playLocalFile(filePath: String, title: String, artist: String) {
|
||||
currentStationUrl = null
|
||||
currentStationName = ""
|
||||
// Publish so the shared NowPlaying bar shows library playback too.
|
||||
_currentStationNameFlow.value = if (artist.isBlank()) title else "$title — $artist"
|
||||
_currentStationUrlFlow.value = null
|
||||
_isLocalFlow.value = true
|
||||
_localMediaFlow.value = title to artist
|
||||
_lastStreamMetaFlow.value = null
|
||||
val ctrl = controller ?: return
|
||||
|
||||
stopAll()
|
||||
|
|
@ -258,6 +283,9 @@ class PlaybackManager(
|
|||
// not an absolute one — that mismatch is what corrupted
|
||||
// boundaries after the buffer wrapped.
|
||||
metadataTimeline.record(metadata, audioBytesAtMetadata)
|
||||
if (metadata.artist.isNotEmpty() || metadata.title.isNotEmpty()) {
|
||||
_lastStreamMetaFlow.value = metadata
|
||||
}
|
||||
listener?.onIcyMetadata(metadata)
|
||||
listener?.onMetadataUpdate(metadata.artist, metadata.title)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ data class StreamMetadata(
|
|||
get() = if (artist.isNotEmpty() && title.isNotEmpty()) title else ""
|
||||
|
||||
companion object {
|
||||
// Server-side template junk, e.g. AzuraCast sending ' - [{{year}}]'
|
||||
// with the placeholder never substituted. Such fields are not metadata.
|
||||
private val PlaceholderRegex = Regex("""\{\{[^}]*\}\}""")
|
||||
|
||||
/** True when [s] is empty or only placeholder/delimiter residue. */
|
||||
private fun isPlaceholder(s: String): Boolean {
|
||||
val cleaned = PlaceholderRegex.replace(s, "")
|
||||
.trim()
|
||||
.removeSurrounding("[", "]")
|
||||
.trim()
|
||||
return cleaned.isEmpty()
|
||||
}
|
||||
|
||||
fun parseStreamTitle(raw: String): StreamMetadata {
|
||||
val cleaned = raw.trim().removeSuffix(";")
|
||||
var artist = ""
|
||||
|
|
@ -31,6 +44,8 @@ data class StreamMetadata(
|
|||
title = value
|
||||
}
|
||||
}
|
||||
if (isPlaceholder(artist)) artist = ""
|
||||
if (isPlaceholder(title)) title = ""
|
||||
|
||||
return StreamMetadata(
|
||||
artist = artist,
|
||||
|
|
|
|||
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.repository.SortOrder
|
||||
import com.rada.ui.theme.AppearanceDialog
|
||||
import com.rada.ui.components.AppHomeButton
|
||||
import com.rada.ui.theme.LocalTokens
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
onBack: () -> Unit,
|
||||
onHome: () -> Unit = {},
|
||||
initialPlaylistId: Long? = null,
|
||||
viewModel: LibraryViewModel = hiltViewModel(),
|
||||
) {
|
||||
|
|
@ -141,8 +143,11 @@ fun LibraryScreen(
|
|||
TopAppBar(
|
||||
title = { Text("Library") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Row(horizontalArrangement = Arrangement.Start) {
|
||||
AppHomeButton(onClick = onHome)
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ import androidx.navigation.compose.NavHost
|
|||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.navArgument
|
||||
import com.rada.ui.theme.LocalReduceMotion
|
||||
import com.rada.ui.theme.detailEnter
|
||||
import com.rada.ui.theme.detailExit
|
||||
import com.rada.ui.theme.detailPopEnter
|
||||
import com.rada.ui.theme.detailPopExit
|
||||
import com.rada.ui.theme.hubEnter
|
||||
import com.rada.ui.theme.hubExit
|
||||
import com.rada.ui.library.LibraryScreen
|
||||
import com.rada.ui.player.NowPlayingBar
|
||||
import com.rada.ui.player.PlayerScreen
|
||||
|
|
@ -24,6 +31,7 @@ fun NavGraph(navController: NavHostController) {
|
|||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
val showNowPlayingBar = currentRoute != Screen.Player.route
|
||||
val reduce = LocalReduceMotion.current
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
|
|
@ -31,7 +39,13 @@ fun NavGraph(navController: NavHostController) {
|
|||
startDestination = Screen.Player.route,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
composable(Screen.Player.route) {
|
||||
composable(
|
||||
Screen.Player.route,
|
||||
enterTransition = { hubEnter(reduce) },
|
||||
exitTransition = { hubExit(reduce) },
|
||||
popEnterTransition = { hubEnter(reduce) },
|
||||
popExitTransition = { hubExit(reduce) },
|
||||
) {
|
||||
PlayerScreen(
|
||||
onNavigateToStations = { navController.navigate(Screen.Stations.route) },
|
||||
onNavigateToLibrary = { navController.navigate(Screen.Library.createRoute()) },
|
||||
|
|
@ -47,28 +61,54 @@ fun NavGraph(navController: NavHostController) {
|
|||
defaultValue = -1L
|
||||
}
|
||||
),
|
||||
enterTransition = { detailEnter(reduce) },
|
||||
exitTransition = { detailExit(reduce) },
|
||||
popEnterTransition = { detailPopEnter(reduce) },
|
||||
popExitTransition = { detailPopExit(reduce) },
|
||||
) { backStackEntry ->
|
||||
val playlistId = backStackEntry.arguments?.getLong("playlistId")?.takeIf { it > 0 }
|
||||
LibraryScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
|
||||
initialPlaylistId = playlistId,
|
||||
)
|
||||
}
|
||||
composable(Screen.Stations.route) {
|
||||
composable(
|
||||
Screen.Stations.route,
|
||||
enterTransition = { detailEnter(reduce) },
|
||||
exitTransition = { detailExit(reduce) },
|
||||
popEnterTransition = { detailPopEnter(reduce) },
|
||||
popExitTransition = { detailPopExit(reduce) },
|
||||
) {
|
||||
StationBrowserScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
|
||||
onStationSelected = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Screen.Streams.route) {
|
||||
composable(
|
||||
Screen.Streams.route,
|
||||
enterTransition = { detailEnter(reduce) },
|
||||
exitTransition = { detailExit(reduce) },
|
||||
popEnterTransition = { detailPopEnter(reduce) },
|
||||
popExitTransition = { detailPopExit(reduce) },
|
||||
) {
|
||||
StreamsScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
|
||||
onStreamSelected = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Screen.Playlists.route) {
|
||||
composable(
|
||||
Screen.Playlists.route,
|
||||
enterTransition = { detailEnter(reduce) },
|
||||
exitTransition = { detailExit(reduce) },
|
||||
popEnterTransition = { detailPopEnter(reduce) },
|
||||
popExitTransition = { detailPopExit(reduce) },
|
||||
) {
|
||||
PlaylistManagerScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
|
||||
onPlaylistSelected = { playlistId ->
|
||||
navController.navigate(Screen.Library.createRoute(playlistId))
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
package com.rada.ui.player
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import com.rada.ui.theme.LocalReduceMotion
|
||||
import com.rada.ui.theme.barEnter
|
||||
import com.rada.ui.theme.barExit
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.filled.Radio
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
|
|
@ -49,16 +51,18 @@ fun NowPlayingBar(
|
|||
viewModel: NowPlayingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val tokens = LocalTokens.current
|
||||
val reduce = LocalReduceMotion.current
|
||||
|
||||
val stationName by viewModel.currentStationName.collectAsState()
|
||||
val isPlaying by viewModel.isPlaying.collectAsState()
|
||||
val bufferState by viewModel.bufferState.collectAsState()
|
||||
val isLocal by viewModel.isLocal.collectAsState()
|
||||
val isVisible = stationName.isNotEmpty()
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = slideInVertically(initialOffsetY = { it }),
|
||||
exit = slideOutVertically(targetOffsetY = { it }),
|
||||
enter = barEnter(reduce),
|
||||
exit = barExit(reduce),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -104,9 +108,9 @@ fun NowPlayingBar(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Radio,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
if (isLocal) Icons.Default.MusicNote else Icons.Default.Radio,
|
||||
contentDescription = if (isLocal) "Now playing" else "Now playing station",
|
||||
tint = LocalTokens.current.accentOnFill,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class NowPlayingViewModel @Inject constructor(
|
|||
val currentStationUrl: StateFlow<String?> = playbackManager.currentStationUrlFlow
|
||||
val isPlaying: StateFlow<Boolean> = playbackManager.isPlayingFlow
|
||||
val bufferState: StateFlow<PlaybackManager.BufferState> = playbackManager.bufferStateFlow
|
||||
val isLocal: StateFlow<Boolean> = playbackManager.isLocalFlow
|
||||
|
||||
fun togglePlayPause() {
|
||||
playbackManager.togglePlayPause()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@ import androidx.compose.animation.fadeIn
|
|||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.togetherWith
|
||||
import com.rada.ui.theme.LocalReduceMotion
|
||||
import com.rada.ui.theme.MotionArrival
|
||||
import com.rada.ui.theme.MotionExit
|
||||
import com.rada.ui.theme.metadataEnter
|
||||
import com.rada.ui.theme.metadataExit
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -16,6 +22,7 @@ import androidx.compose.foundation.layout.Row
|
|||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -30,6 +37,7 @@ import androidx.compose.material.icons.filled.Add
|
|||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.LibraryMusic
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||
|
|
@ -37,6 +45,7 @@ import androidx.compose.material.icons.filled.Podcasts
|
|||
import androidx.compose.material.icons.filled.Radio
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
|
|
@ -47,6 +56,7 @@ import androidx.compose.material3.Button
|
|||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
|
|
@ -89,7 +99,9 @@ import androidx.compose.ui.unit.sp
|
|||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.rada.domain.model.Station
|
||||
import com.rada.player.PlaybackManager
|
||||
import com.rada.ui.components.AppHomeButton
|
||||
import com.rada.ui.theme.LocalTokens
|
||||
import com.rada.ui.theme.AppearanceDialog
|
||||
import com.rada.ui.theme.GradientEnd
|
||||
import com.rada.ui.theme.GradientMid
|
||||
import com.rada.ui.theme.PremiumCyan
|
||||
|
|
@ -107,6 +119,7 @@ fun PlayerScreen(
|
|||
val importMessage by viewModel.importMessage.collectAsState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var showAppearance by remember { mutableStateOf(false) }
|
||||
|
||||
val importSongsPicker = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenMultipleDocuments()
|
||||
|
|
@ -183,9 +196,42 @@ fun PlayerScreen(
|
|||
)
|
||||
}
|
||||
|
||||
if (showAppearance) {
|
||||
AppearanceDialog(onDismiss = { showAppearance = false })
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
// Home slot: the app icon top-left on every surface. On the hub
|
||||
// there is nowhere further home to go, so it reads as the brand
|
||||
// mark; detail screens wire it to pop back here.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AppHomeButton(onClick = { /* already home */ })
|
||||
Text(
|
||||
text = "SatsAmp",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = LocalTokens.current.text,
|
||||
)
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
// Appearance settings reachable from home too (same dialog as Library).
|
||||
FloatingActionButton(
|
||||
onClick = { showAppearance = true },
|
||||
containerColor = LocalTokens.current.panel.copy(alpha = 0.94f),
|
||||
contentColor = LocalTokens.current.textSecondary,
|
||||
) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Appearance settings")
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
BottomAppBar(
|
||||
containerColor = LocalTokens.current.panel.copy(alpha = 0.94f),
|
||||
|
|
@ -238,7 +284,7 @@ fun PlayerScreen(
|
|||
}
|
||||
},
|
||||
) { padding ->
|
||||
if (uiState.currentStation != null) {
|
||||
if (uiState.currentStation != null || uiState.isLocal) {
|
||||
NowPlayingView(
|
||||
uiState = uiState,
|
||||
viewModel = viewModel,
|
||||
|
|
@ -388,14 +434,18 @@ private fun WaveGraphic(modifier: Modifier = Modifier) {
|
|||
)
|
||||
}
|
||||
|
||||
// Draw subtle glow at center
|
||||
// Ambient glow behind the waves — wide, faint, no visible disc edge.
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(accentGlow.copy(alpha = 0.15f), Color.Transparent),
|
||||
colorStops = arrayOf(
|
||||
0f to accentGlow.copy(alpha = 0.06f),
|
||||
0.55f to accentGlow.copy(alpha = 0.025f),
|
||||
1f to Color.Transparent,
|
||||
),
|
||||
center = Offset(width / 2, centerY),
|
||||
radius = width / 3,
|
||||
radius = width * 0.75f,
|
||||
),
|
||||
radius = width / 3,
|
||||
radius = width * 0.75f,
|
||||
center = Offset(width / 2, centerY),
|
||||
)
|
||||
}
|
||||
|
|
@ -407,6 +457,7 @@ private fun NowPlayingView(
|
|||
viewModel: PlayerViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val reduce = LocalReduceMotion.current
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -422,13 +473,64 @@ private fun NowPlayingView(
|
|||
bufferState = uiState.bufferState,
|
||||
)
|
||||
}
|
||||
// Local library playback: a card for the song instead of a station.
|
||||
if (uiState.currentStation == null && uiState.isLocal) {
|
||||
androidx.compose.material3.Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(LocalTokens.current.accentFill.copy(alpha = 0.2f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.MusicNote,
|
||||
contentDescription = null,
|
||||
tint = LocalTokens.current.accentFill,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = uiState.localTitle,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (uiState.localArtist.isNotEmpty()) {
|
||||
Text(
|
||||
text = uiState.localArtist,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Artist/Station name
|
||||
AnimatedContent(
|
||||
targetState = uiState.artist,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
|
||||
label = "artist",
|
||||
) { artist ->
|
||||
Text(
|
||||
|
|
@ -444,7 +546,7 @@ private fun NowPlayingView(
|
|||
// Song title
|
||||
AnimatedContent(
|
||||
targetState = uiState.title,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
|
||||
label = "title",
|
||||
) { title ->
|
||||
if (title.isNotEmpty()) {
|
||||
|
|
@ -459,7 +561,7 @@ private fun NowPlayingView(
|
|||
)
|
||||
} else if (uiState.isPlaying && uiState.currentStation != null) {
|
||||
Text(
|
||||
text = if (uiState.hasMetadata) "" else "No metadata",
|
||||
text = if (uiState.hasMetadata) "" else "No track info from this station",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
textAlign = TextAlign.Center,
|
||||
|
|
@ -479,14 +581,27 @@ private fun NowPlayingView(
|
|||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Playback controls
|
||||
// Playback controls — conventional transport order: stop · prev · play · next
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { viewModel.stop() },
|
||||
enabled = uiState.currentStation != null || uiState.isLocal,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Stop,
|
||||
contentDescription = "Stop",
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
IconButton(
|
||||
onClick = { viewModel.previousStation() },
|
||||
enabled = uiState.currentStation != null,
|
||||
enabled = uiState.currentStation != null || uiState.isLocal,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.SkipPrevious,
|
||||
|
|
@ -497,42 +612,48 @@ private fun NowPlayingView(
|
|||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
IconButton(
|
||||
onClick = { viewModel.stop() },
|
||||
enabled = uiState.currentStation != null,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Stop,
|
||||
contentDescription = "Stop",
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
FilledIconButton(
|
||||
onClick = { viewModel.togglePlayPause() },
|
||||
enabled = uiState.currentStation != null,
|
||||
enabled = uiState.currentStation != null || uiState.isLocal,
|
||||
modifier = Modifier.size(72.dp),
|
||||
shape = CircleShape,
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) {
|
||||
if (uiState.isPlaying) {
|
||||
AnimatedContent(
|
||||
targetState = uiState.isPlaying,
|
||||
transitionSpec = {
|
||||
if (reduce)
|
||||
fadeIn(tween(0)) togetherWith fadeOut(tween(0))
|
||||
else
|
||||
scaleIn(tween(130, easing = MotionArrival), initialScale = 0.7f) +
|
||||
fadeIn(tween(90)) togetherWith
|
||||
scaleOut(tween(90, easing = MotionExit), targetScale = 0.7f) +
|
||||
fadeOut(tween(90))
|
||||
},
|
||||
label = "playPause",
|
||||
) { playing ->
|
||||
Icon(
|
||||
Icons.Default.Pause,
|
||||
contentDescription = "Pause",
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = "Play",
|
||||
if (playing) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = if (playing) "Pause" else "Play",
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
IconButton(
|
||||
onClick = { viewModel.nextStation() },
|
||||
enabled = uiState.currentStation != null,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.SkipNext,
|
||||
contentDescription = "Next station",
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -691,6 +812,7 @@ private fun SaveSongButton(
|
|||
saveState: PlaybackManager.SaveState,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val reduce = LocalReduceMotion.current
|
||||
val containerColor = when {
|
||||
saveState == PlaybackManager.SaveState.SAVING -> MaterialTheme.colorScheme.secondary
|
||||
saveState == PlaybackManager.SaveState.SUCCESS -> Color(0xFF10B981)
|
||||
|
|
@ -724,8 +846,11 @@ private fun SaveSongButton(
|
|||
AnimatedContent(
|
||||
targetState = saveState,
|
||||
transitionSpec = {
|
||||
scaleIn(initialScale = 0.8f) + fadeIn() togetherWith
|
||||
scaleOut(targetScale = 0.8f) + fadeOut()
|
||||
if (reduce)
|
||||
fadeIn(tween(140)) togetherWith fadeOut(tween(140))
|
||||
else
|
||||
scaleIn(initialScale = 0.8f) + fadeIn() togetherWith
|
||||
scaleOut(targetScale = 0.8f) + fadeOut()
|
||||
},
|
||||
label = "saveIcon",
|
||||
) { state ->
|
||||
|
|
|
|||
|
|
@ -139,9 +139,27 @@ class PlayerViewModel @Inject constructor(
|
|||
currentStation = playbackManager.currentStationUrl?.let { url ->
|
||||
Station(id = url, name = playbackManager.currentStationName, url = url)
|
||||
},
|
||||
isLocal = playbackManager.isLocalFlow.value,
|
||||
localTitle = playbackManager.localMediaFlow.value?.first.orEmpty(),
|
||||
localArtist = playbackManager.localMediaFlow.value?.second.orEmpty(),
|
||||
isPlaying = playbackManager.isPlaying,
|
||||
)
|
||||
}
|
||||
restoreCachedStreamMeta()
|
||||
}
|
||||
|
||||
/** Re-apply the last ICY metadata so a reopened screen isn't blank. */
|
||||
private fun restoreCachedStreamMeta() {
|
||||
playbackManager.lastStreamMetaFlow.value?.let { meta ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
artist = meta.displayArtist.ifEmpty { it.artist },
|
||||
title = meta.displayTitle,
|
||||
hasMetadata = true,
|
||||
lastMetadata = meta,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun playStation(station: Station) {
|
||||
|
|
@ -255,16 +273,23 @@ class PlayerViewModel @Inject constructor(
|
|||
fun syncPlaybackState() {
|
||||
playbackManager.listener = playbackListener
|
||||
val url = playbackManager.currentStationUrl
|
||||
val isLocal = playbackManager.isLocalFlow.value
|
||||
val local = playbackManager.localMediaFlow.value
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
stations = stationRepository.getAll(),
|
||||
currentStation = url?.let { u ->
|
||||
Station(id = u, name = playbackManager.currentStationName, url = u)
|
||||
},
|
||||
isLocal = isLocal,
|
||||
localTitle = local?.first.orEmpty(),
|
||||
localArtist = local?.second.orEmpty(),
|
||||
isPlaying = playbackManager.isPlaying,
|
||||
artist = playbackManager.currentStationName,
|
||||
artist = if (isLocal) local?.second.orEmpty() else playbackManager.currentStationName,
|
||||
title = if (isLocal) local?.first.orEmpty() else it.title,
|
||||
)
|
||||
}
|
||||
if (!isLocal) restoreCachedStreamMeta()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
|
|
@ -275,6 +300,9 @@ class PlayerViewModel @Inject constructor(
|
|||
|
||||
data class PlayerUiState(
|
||||
val currentStation: Station? = null,
|
||||
val isLocal: Boolean = false,
|
||||
val localTitle: String = "",
|
||||
val localArtist: String = "",
|
||||
val isPlaying: Boolean = false,
|
||||
val artist: String = "",
|
||||
val title: String = "",
|
||||
|
|
|
|||
|
|
@ -57,12 +57,14 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.rada.data.local.entity.PlaylistEntity
|
||||
import com.rada.ui.components.AppHomeButton
|
||||
import com.rada.ui.theme.LocalTokens
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlaylistManagerScreen(
|
||||
onBack: () -> Unit,
|
||||
onHome: () -> Unit = {},
|
||||
onPlaylistSelected: (Long) -> Unit = {},
|
||||
viewModel: PlaylistManagerViewModel = hiltViewModel(),
|
||||
) {
|
||||
|
|
@ -133,8 +135,11 @@ fun PlaylistManagerScreen(
|
|||
TopAppBar(
|
||||
title = { Text("Playlists") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Row(horizontalArrangement = Arrangement.Start) {
|
||||
AppHomeButton(onClick = onHome)
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Radio
|
||||
|
|
@ -27,6 +31,7 @@ import androidx.compose.material.icons.filled.Search
|
|||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
|
@ -49,9 +54,11 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.rada.domain.model.Station
|
||||
import com.rada.ui.components.AppHomeButton
|
||||
import com.rada.ui.theme.LocalTokens
|
||||
|
||||
private val genreColors = mapOf(
|
||||
|
|
@ -75,11 +82,15 @@ private val genreColors = mapOf(
|
|||
@Composable
|
||||
fun StationBrowserScreen(
|
||||
onBack: () -> Unit,
|
||||
onHome: () -> Unit = {},
|
||||
onStationSelected: () -> Unit = {},
|
||||
viewModel: StationBrowserViewModel = hiltViewModel(),
|
||||
) {
|
||||
val stationsByGenre by viewModel.stationsByGenre.collectAsState()
|
||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||
val favorites by viewModel.favorites.collectAsState()
|
||||
val directoryState by viewModel.directoryState.collectAsState()
|
||||
val playingUrl by viewModel.playingUrl.collectAsState()
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
var renameTarget by remember { mutableStateOf<Station?>(null) }
|
||||
|
||||
|
|
@ -115,8 +126,11 @@ fun StationBrowserScreen(
|
|||
TopAppBar(
|
||||
title = { Text("Stations") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Row(horizontalArrangement = Arrangement.Start) {
|
||||
AppHomeButton(onClick = onHome)
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
|
|
@ -162,6 +176,27 @@ fun StationBrowserScreen(
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// ── Favorites row: pinned above everything ──
|
||||
if (favorites.isNotEmpty()) {
|
||||
item { SectionHeader("Starred", Icons.Default.Star) }
|
||||
items(favorites, key = { "fav-${it.id}" }) { station ->
|
||||
StationCard(
|
||||
station = station,
|
||||
isCustom = viewModel.isCustomStation(station.id),
|
||||
isFavorite = true,
|
||||
isPlaying = playingUrl == station.url,
|
||||
onClick = {
|
||||
viewModel.playFavorite(station)
|
||||
onStationSelected()
|
||||
},
|
||||
onToggleFavorite = { viewModel.toggleFavorite(station) },
|
||||
onRename = { renameTarget = station },
|
||||
onDelete = { viewModel.removeCustomStation(station.id) },
|
||||
)
|
||||
}
|
||||
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||
}
|
||||
|
||||
stationsByGenre.forEach { (genre, stations) ->
|
||||
item {
|
||||
GenreHeader(genre = genre, count = stations.size)
|
||||
|
|
@ -170,10 +205,13 @@ fun StationBrowserScreen(
|
|||
StationCard(
|
||||
station = station,
|
||||
isCustom = viewModel.isCustomStation(station.id),
|
||||
isFavorite = viewModel.isFavorite(station.id),
|
||||
isPlaying = playingUrl == station.url,
|
||||
onClick = {
|
||||
viewModel.selectStation(station)
|
||||
onStationSelected()
|
||||
},
|
||||
onToggleFavorite = { viewModel.toggleFavorite(station) },
|
||||
onRename = { renameTarget = station },
|
||||
onDelete = {
|
||||
viewModel.removeCustomStation(station.id)
|
||||
|
|
@ -184,6 +222,72 @@ fun StationBrowserScreen(
|
|||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Directory results (radio-browser.info) ──
|
||||
when (val ds = directoryState) {
|
||||
DirectoryState.Idle -> Unit
|
||||
DirectoryState.Loading -> item {
|
||||
Column {
|
||||
DirectorySectionHeader("Searching 58,000+ stations…")
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(22.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = LocalTokens.current.accentText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is DirectoryState.Error -> item {
|
||||
Column {
|
||||
DirectorySectionHeader("Directory")
|
||||
Text(
|
||||
text = "${ds.message}\nCheck your connection and search again.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = LocalTokens.current.textSecondary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
is DirectoryState.Results -> {
|
||||
if (ds.stations.isNotEmpty()) {
|
||||
item { DirectorySectionHeader("From the directory") }
|
||||
items(ds.stations, key = { "rb-${it.id}" }) { station ->
|
||||
StationCard(
|
||||
station = station,
|
||||
isCustom = false,
|
||||
isFavorite = viewModel.isFavorite(station.id),
|
||||
isPlaying = playingUrl == station.url,
|
||||
onClick = {
|
||||
viewModel.selectStation(station)
|
||||
onStationSelected()
|
||||
},
|
||||
onToggleFavorite = { viewModel.toggleFavorite(station) },
|
||||
onRename = {},
|
||||
onDelete = {},
|
||||
onAdd = { viewModel.addDiscoveredStation(station) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
Column {
|
||||
DirectorySectionHeader("From the directory")
|
||||
Text(
|
||||
text = "No directory matches.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = LocalTokens.current.textSecondary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -363,37 +467,45 @@ private fun GenreHeader(genre: String, count: Int) {
|
|||
private fun StationCard(
|
||||
station: Station,
|
||||
isCustom: Boolean,
|
||||
isFavorite: Boolean,
|
||||
isPlaying: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onAdd: (() -> Unit)? = null,
|
||||
) {
|
||||
val tokens = LocalTokens.current
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = LocalTokens.current.glassFillStrong,
|
||||
containerColor = if (isPlaying) tokens.accentSoft else tokens.glassFillStrong,
|
||||
),
|
||||
border = if (isPlaying) {
|
||||
androidx.compose.foundation.BorderStroke(1.dp, tokens.accentFill.copy(alpha = 0.6f))
|
||||
} else null,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(LocalTokens.current.accentFill.copy(alpha = 0.15f)),
|
||||
.background(tokens.accentFill.copy(alpha = 0.15f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Radio,
|
||||
if (isPlaying) Icons.Default.PlayArrow else Icons.Default.Radio,
|
||||
contentDescription = null,
|
||||
tint = LocalTokens.current.accentText,
|
||||
tint = tokens.accentText,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
|
@ -402,35 +514,108 @@ private fun StationCard(
|
|||
Text(
|
||||
text = station.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = LocalTokens.current.text,
|
||||
fontWeight = if (isPlaying) FontWeight.Bold else FontWeight.SemiBold,
|
||||
color = tokens.text,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (station.genre.isNotEmpty()) {
|
||||
val subtitle = station.description.ifBlank { station.genre }
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = station.genre,
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = LocalTokens.current.textSecondary,
|
||||
color = tokens.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onRename) {
|
||||
IconButton(onClick = onToggleFavorite, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Rename",
|
||||
tint = LocalTokens.current.textSecondary,
|
||||
imageVector = if (isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
|
||||
contentDescription = if (isFavorite) "Unstar" else "Star",
|
||||
tint = if (isFavorite) tokens.accentFill else tokens.textSecondary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
if (isCustom) {
|
||||
IconButton(onClick = onDelete) {
|
||||
// Directory stations: offer saving into the local list.
|
||||
if (onAdd != null) {
|
||||
IconButton(onClick = onAdd, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Remove",
|
||||
tint = LocalTokens.current.textSecondary,
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Save to stations",
|
||||
tint = tokens.textSecondary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = onRename, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Rename",
|
||||
tint = tokens.textSecondary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
if (isCustom) {
|
||||
IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Remove",
|
||||
tint = tokens.textSecondary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String, icon: androidx.compose.ui.graphics.vector.ImageVector) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = LocalTokens.current.accentFill,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = LocalTokens.current.accentText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DirectorySectionHeader(title: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Language,
|
||||
contentDescription = null,
|
||||
tint = LocalTokens.current.textSecondary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = LocalTokens.current.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,22 +2,37 @@ package com.rada.ui.stations
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rada.data.repository.RadioBrowserRepository
|
||||
import com.rada.data.repository.StationRepository
|
||||
import com.rada.domain.model.Station
|
||||
import com.rada.player.PlaybackManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Directory (radio-browser.info) search state. */
|
||||
sealed interface DirectoryState {
|
||||
data object Idle : DirectoryState
|
||||
data object Loading : DirectoryState
|
||||
data class Results(val stations: List<Station>) : DirectoryState
|
||||
data class Error(val message: String) : DirectoryState
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@HiltViewModel
|
||||
class StationBrowserViewModel @Inject constructor(
|
||||
private val stationRepository: StationRepository,
|
||||
private val radioBrowserRepository: RadioBrowserRepository,
|
||||
private val playbackManager: PlaybackManager,
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -26,6 +41,11 @@ class StationBrowserViewModel @Inject constructor(
|
|||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
val favorites: StateFlow<List<Station>> = stationRepository.favorites
|
||||
|
||||
/** URL of whatever is playing now, so the list can mark the active row. */
|
||||
val playingUrl: StateFlow<String?> = playbackManager.currentStationUrlFlow
|
||||
|
||||
val stations: StateFlow<List<Station>> = combine(_allStations, _searchQuery) { list, query ->
|
||||
val filtered = if (query.isBlank()) list
|
||||
else list.filter {
|
||||
|
|
@ -48,6 +68,33 @@ class StationBrowserViewModel @Inject constructor(
|
|||
.toSortedMap(compareBy { it.lowercase() })
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
|
||||
|
||||
// ── Directory search (radio-browser.info, ~58k stations) ───────────
|
||||
|
||||
private val _directoryState = MutableStateFlow<DirectoryState>(DirectoryState.Idle)
|
||||
val directoryState: StateFlow<DirectoryState> = _directoryState.asStateFlow()
|
||||
|
||||
init {
|
||||
// Debounced: search the wider directory only once typing settles.
|
||||
_searchQuery
|
||||
.debounce(450)
|
||||
.distinctUntilChanged()
|
||||
.onEach { query ->
|
||||
val q = query.trim()
|
||||
when {
|
||||
q.length < 2 -> _directoryState.value = DirectoryState.Idle
|
||||
else -> {
|
||||
_directoryState.value = DirectoryState.Loading
|
||||
_directoryState.value = try {
|
||||
DirectoryState.Results(radioBrowserRepository.search(q))
|
||||
} catch (e: Exception) {
|
||||
DirectoryState.Error(e.message ?: "Directory search failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
|
@ -56,6 +103,15 @@ class StationBrowserViewModel @Inject constructor(
|
|||
playbackManager.playStation(station.url, station.name)
|
||||
}
|
||||
|
||||
fun isFavorite(id: String): Boolean = stationRepository.isFavorite(id)
|
||||
|
||||
fun toggleFavorite(station: Station) = stationRepository.toggleFavorite(station)
|
||||
|
||||
/** Play a favorite and, if it is new, keep it in the custom list too. */
|
||||
fun playFavorite(station: Station) {
|
||||
playbackManager.playStation(station.url, station.name)
|
||||
}
|
||||
|
||||
fun addCustomStation(name: String, url: String) {
|
||||
if (name.isBlank() || url.isBlank()) return
|
||||
val id = "custom-${System.currentTimeMillis()}"
|
||||
|
|
@ -69,6 +125,13 @@ class StationBrowserViewModel @Inject constructor(
|
|||
_allStations.value = stationRepository.getAll()
|
||||
}
|
||||
|
||||
/** Save a directory/discovered station into the local list as "Custom". */
|
||||
fun addDiscoveredStation(station: Station) {
|
||||
if (stationRepository.getById(station.id) != null) return
|
||||
stationRepository.addStation(station.copy(genre = station.genre.ifBlank { "Custom" }))
|
||||
_allStations.value = stationRepository.getAll()
|
||||
}
|
||||
|
||||
fun removeCustomStation(id: String) {
|
||||
stationRepository.removeStation(id)
|
||||
_allStations.value = stationRepository.getAll()
|
||||
|
|
|
|||
|
|
@ -55,12 +55,14 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.rada.domain.model.StreamEntry
|
||||
import com.rada.ui.components.AppHomeButton
|
||||
import com.rada.ui.theme.LocalTokens
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StreamsScreen(
|
||||
onBack: () -> Unit,
|
||||
onHome: () -> Unit = {},
|
||||
onStreamSelected: () -> Unit = {},
|
||||
viewModel: StreamsViewModel = hiltViewModel(),
|
||||
) {
|
||||
|
|
@ -101,8 +103,11 @@ fun StreamsScreen(
|
|||
TopAppBar(
|
||||
title = { Text("Streams") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Row(horizontalArrangement = Arrangement.Start) {
|
||||
AppHomeButton(onClick = onHome)
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
|
|
|
|||
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.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import android.provider.Settings
|
||||
|
||||
val LocalTokens = staticCompositionLocalOf { darkTokens(AccentMode.VIOLET) }
|
||||
|
||||
|
|
@ -24,6 +26,10 @@ fun SatsAmpTheme(
|
|||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
// System "Remove animations" (window animator duration scale) → reduce path.
|
||||
val reduceMotion = Settings.Global.getFloat(
|
||||
context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f
|
||||
) == 0f
|
||||
val dark = when (UiPrefs.mode) {
|
||||
AppMode.DARK -> true
|
||||
AppMode.LIGHT -> false
|
||||
|
|
@ -53,7 +59,15 @@ fun SatsAmpTheme(
|
|||
surfaceTint = tok.accentFill,
|
||||
)
|
||||
val colorScheme = if (dark) buildDark(schemeArgs) else buildLight(schemeArgs)
|
||||
CompositionLocalProvider(LocalTokens provides tok) {
|
||||
// Surfaces/Scaffolds use Color.Transparent containers, so M3's
|
||||
// contentColorFor() cannot resolve onX colors and falls back to the root
|
||||
// LocalContentColor (Color.Black). Provide the theme's own text color so
|
||||
// uncolored Text/Icon stay legible in dark mode.
|
||||
CompositionLocalProvider(
|
||||
LocalTokens provides tok,
|
||||
LocalContentColor provides tok.text,
|
||||
LocalReduceMotion provides reduceMotion,
|
||||
) {
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue