Compare commits

...

6 commits

25 changed files with 729 additions and 110 deletions

BIN
Satsamp Icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

View file

@ -54,6 +54,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
}

View file

@ -8,6 +8,8 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- In-app self-update: launching the package installer for a downloaded APK. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- NIP-55 external signer discovery (Amber, etc.) -->
@ -48,6 +50,17 @@
</intent-filter>
</service>
<!-- Shares update APKs from cacheDir/apks to the package installer. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -465,11 +465,18 @@ class PlaybackManager(
}
private fun startIcyReader(url: String) {
// Never leave a previous reader's socket open: an unclosed stream
// keeps its OkHttp connection (and its own thread) streaming forever,
// and every ghost feeds the same now-playing field — that is how four
// different songs flickered over one another.
stopIcyReader()
val reader = IcyStreamReader()
icyReader = reader
reader.listener = object : IcyStreamReader.Listener {
override fun onMetadata(metadata: StreamMetadata, audioBytesAtMetadata: Long) {
// Drop callbacks from a reader we already replaced or stopped.
if (icyReader !== reader) return
Log.d(TAG, "ICY metadata: ${metadata.artist} - ${metadata.title} @ $audioBytesAtMetadata")
// Record at the reader-reported ABSOLUTE audio position (the same
// quantity as audioBuffer.totalBytes). The previous code read
@ -486,6 +493,7 @@ class PlaybackManager(
}
override fun onAudioBytes(data: ByteArray, offset: Int, length: Long) {
if (icyReader !== reader) return
audioBuffer?.write(data, offset, length.toInt())
listener?.onBufferBytesWritten(audioBuffer?.totalBytes ?: 0)
@ -495,6 +503,7 @@ class PlaybackManager(
}
override fun onError(error: String) {
if (icyReader !== reader) return
Log.w(TAG, "ICY reader error: $error")
// ExoPlayer often rides out a transient drop on its own
// buffer without erroring; if it does, the recorder would

View file

@ -18,6 +18,12 @@ class IcyStreamReader(
var isRunning: Boolean = false
private set
/** True once stop() was called by request. A close() racing a blocked
* stream.read() throws IOException, NOT CancellationException — without
* this flag the generic catch reports an intentional stop as an error,
* and the manager's error-restart resurrects a ghost reader. */
@Volatile private var requestedStop = false
private var metaInterval: Int = 0
private var bytesInInterval: Int = 0
private var pendingMetaBytes: Int = 0
@ -52,6 +58,7 @@ class IcyStreamReader(
fun start(streamUrl: String) {
if (isRunning) stop()
requestedStop = false
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope?.launch {
try {
@ -59,8 +66,10 @@ class IcyStreamReader(
} catch (e: CancellationException) {
// Normal stop
} catch (e: Exception) {
Log.e(TAG, "Error reading stream", e)
listener?.onError(e.message ?: "Connection error")
if (!requestedStop) {
Log.e(TAG, "Error reading stream", e)
listener?.onError(e.message ?: "Connection error")
}
} finally {
isRunning = false
}
@ -68,6 +77,7 @@ class IcyStreamReader(
}
fun stop() {
requestedStop = true
scope?.cancel()
scope = null
closeConnection()

View file

@ -23,6 +23,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Radio
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.AlertDialog
@ -61,6 +62,7 @@ import androidx.compose.ui.graphics.Color
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.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.data.wavlake.MatchingMode
import com.rada.data.wavlake.WavlakeSearchHit
@ -204,6 +206,25 @@ fun DiscoverScreen(
shape = RoundedCornerShape(12.dp),
)
// What to search: toggle Artists / Tracks / Genres / Stations.
val selectedTypes by viewModel.selectedTypes.collectAsState()
LazyRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
horizontal = 16.dp,
),
) {
items(DiscoverType.entries.toList(), key = { "type-${it.name}" }) { type ->
GenreChip(
label = type.label,
selected = type in selectedTypes,
onClick = { viewModel.toggleType(type) },
)
}
}
Spacer(modifier = Modifier.height(6.dp))
when (val s = searchState) {
WavlakeSearchState.Idle -> {
GenreChips(
@ -247,33 +268,142 @@ fun DiscoverScreen(
}
}
is WavlakeSearchState.Results -> {
val r = s.results
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(s.hits, key = { it.stableId() }, contentType = { "hit" }) { hit ->
when (hit) {
is WavlakeSearchHit.Artist -> ArtistRow(
name = hit.artist.name,
genre = hit.artist.genre,
imageUrl = hit.artist.imageUrl,
artistId = hit.artist.id,
address = addresses[hit.artist.id],
onClick = {
viewModel.openArtist(hit.artist.id, hit.artist.name)
},
if (r.stations.isNotEmpty()) {
item(key = "sec-stations", contentType = "header") {
SectionHeader("Stations")
}
items(r.stations, key = { "st-${it.id}" }, contentType = { "station" }) { st ->
Card(
onClick = { viewModel.playFoundStation(st) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
colors = CardDefaults.cardColors(
containerColor = LocalTokens.current.glassFillStrong,
),
shape = RoundedCornerShape(SatsAmpGlass.rInput),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
LocalTokens.current.accentFill.copy(alpha = 0.15f),
),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Radio,
contentDescription = null,
tint = LocalTokens.current.accentText,
modifier = Modifier.size(20.dp),
)
}
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = st.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = LocalTokens.current.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = st.genre.ifBlank { "Radio" },
style = MaterialTheme.typography.bodySmall,
color = LocalTokens.current.textSecondary,
maxLines = 1,
)
}
IconButton(
onClick = { viewModel.playFoundStation(st) },
modifier = Modifier.size(48.dp),
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = "Play ${st.name}",
tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(22.dp),
)
}
}
}
}
}
if (r.genres.isNotEmpty()) {
item(key = "sec-genres", contentType = "header") {
SectionHeader("Genres")
}
items(r.genres, key = { "gen-$it" }, contentType = { "genre" }) { genre ->
Card(
onClick = { viewModel.openGenre(genre) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
colors = CardDefaults.cardColors(
containerColor = LocalTokens.current.glassFillStrong,
),
shape = RoundedCornerShape(SatsAmpGlass.rInput),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = genre,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = LocalTokens.current.text,
modifier = Modifier.weight(1f),
)
Text(
text = "Browse",
style = MaterialTheme.typography.labelLarge,
color = LocalTokens.current.accentText,
)
}
}
}
}
if (r.artists.isNotEmpty()) {
item(key = "sec-artists", contentType = "header") {
SectionHeader("Artists")
}
items(r.artists, key = { "artist-${it.id}" }, contentType = { "artist" }) { artist ->
ArtistRow(
name = artist.name,
genre = artist.genre,
imageUrl = artist.imageUrl,
artistId = artist.id,
address = addresses[artist.id],
onClick = { viewModel.openArtist(artist.id, artist.name) },
onZap = {
viewModel.resolveArtistAddress(hit.artist.id)
pendingZapArtistId = hit.artist.id
viewModel.resolveArtistAddress(artist.id)
pendingZapArtistId = artist.id
},
)
is WavlakeSearchHit.Album -> AlbumRow(
title = hit.title,
artist = hit.artist,
artworkUrl = hit.artworkUrl,
onClick = { /* albums open via artist detail */ },
)
is WavlakeSearchHit.Track -> TrackRow(
}
}
if (r.tracks.isNotEmpty() || r.albums.isNotEmpty()) {
item(key = "sec-tracks", contentType = "header") {
SectionHeader("Tracks")
}
items(r.tracks, key = { "track-${it.track.id}" }, contentType = { "track" }) { hit ->
TrackRow(
track = com.rada.domain.model.WavlakeTrack(
id = hit.track.id,
title = hit.track.title,
@ -317,6 +447,14 @@ fun DiscoverScreen(
},
)
}
items(r.albums, key = { "album-${it.id}" }, contentType = { "album" }) { album ->
AlbumRow(
title = album.title,
artist = album.artist,
artworkUrl = album.artworkUrl,
onClick = { /* albums open via artist detail */ },
)
}
}
}
}
@ -334,6 +472,18 @@ fun DiscoverScreen(
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
letterSpacing = 0.6.sp,
color = LocalTokens.current.accentText,
modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 2.dp),
)
}
private data class ZapTarget(
val artistId: String,
val artistName: String,

View file

@ -26,10 +26,27 @@ import javax.inject.Inject
/** Curated genre shortcuts — exact Wavlake catalog names only. */
val DISCOVER_GENRES = listOf("Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock")
/** What the Discover search looks for; user-toggled chips. */
enum class DiscoverType(val label: String) {
ARTISTS("Artists"), TRACKS("Tracks"), GENRES("Genres"), STATIONS("Stations"),
}
/** Search results bucketed by type — only selected types are populated. */
data class DiscoverResults(
val artists: List<WavlakeArtist> = emptyList(),
val tracks: List<WavlakeSearchHit.Track> = emptyList(),
val albums: List<WavlakeSearchHit.Album> = emptyList(),
val genres: List<String> = emptyList(),
val stations: List<com.rada.domain.model.Station> = emptyList(),
) {
fun isEmpty() = artists.isEmpty() && tracks.isEmpty() && albums.isEmpty() &&
genres.isEmpty() && stations.isEmpty()
}
sealed interface WavlakeSearchState {
data object Idle : WavlakeSearchState
data object Loading : WavlakeSearchState
data class Results(val hits: List<WavlakeSearchHit>) : WavlakeSearchState
data class Results(val results: DiscoverResults) : WavlakeSearchState
data class Error(val message: String) : WavlakeSearchState
}
@ -39,11 +56,39 @@ class WavlakeViewModel @Inject constructor(
private val repository: WavlakeRepository,
private val playbackManager: PlaybackManager,
private val playlists: PlaylistRepository,
private val stations: com.rada.data.repository.StationRepository,
@dagger.hilt.android.qualifiers.ApplicationContext private val appContext: android.content.Context,
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
/** Which result types to search/show; persisted so the choice sticks. */
private val _selectedTypes = MutableStateFlow(loadTypes())
val selectedTypes: StateFlow<Set<DiscoverType>> = _selectedTypes.asStateFlow()
fun toggleType(type: DiscoverType) {
val current = _selectedTypes.value.toMutableSet()
if (type in current) {
// Never allow an empty selection — re-tapping the last one keeps it.
if (current.size == 1) return
current -= type
} else current += type
_selectedTypes.value = current
appContext.getSharedPreferences("discover_prefs", android.content.Context.MODE_PRIVATE)
.edit().putStringSet("types", current.map { it.name }.toSet()).apply()
// Re-run the current query so results reflect the new selection now.
val q = _searchQuery.value
if (q.trim().length >= 2) _searchQuery.value = "$q "
}
private fun loadTypes(): Set<DiscoverType> {
val saved = appContext.getSharedPreferences("discover_prefs", android.content.Context.MODE_PRIVATE)
.getStringSet("types", null)
val parsed = saved?.mapNotNull { runCatching { DiscoverType.valueOf(it) }.getOrNull() }?.toSet()
return parsed?.takeIf { it.isNotEmpty() } ?: DiscoverType.entries.toSet()
}
private val _searchState = MutableStateFlow<WavlakeSearchState>(WavlakeSearchState.Idle)
val searchState: StateFlow<WavlakeSearchState> = _searchState.asStateFlow()
@ -89,17 +134,60 @@ class WavlakeViewModel @Inject constructor(
return@onEach
}
_searchState.value = WavlakeSearchState.Loading
_searchState.value = when (val r = repository.search(query)) {
is WavlakeResult.Ok ->
if (r.value.isEmpty()) WavlakeSearchState.Error("No Wavlake results.")
else WavlakeSearchState.Results(r.value)
is WavlakeResult.Failed -> WavlakeSearchState.Error(r.message)
val types = _selectedTypes.value
// Local stations need no network; Wavlake hit types share one call.
val needRemote = types.any {
it != DiscoverType.GENRES && it != DiscoverType.STATIONS
}
val hits = if (needRemote) {
when (val r = repository.search(query)) {
is WavlakeResult.Ok -> r.value
is WavlakeResult.Failed -> {
// Stations alone can still answer; otherwise surface the error.
if (types.contains(DiscoverType.STATIONS)) emptyList()
else {
_searchState.value = WavlakeSearchState.Error(r.message)
return@onEach
}
}
}
} else emptyList()
val matchQ = query.lowercase()
val bucket = DiscoverResults(
artists = hits.filterIsInstance<WavlakeSearchHit.Artist>()
.filter { DiscoverType.ARTISTS in types }.map { it.artist },
tracks = hits.filterIsInstance<WavlakeSearchHit.Track>()
.filter { DiscoverType.TRACKS in types },
albums = hits.filterIsInstance<WavlakeSearchHit.Album>()
.filter { DiscoverType.TRACKS in types },
genres = (if (DiscoverType.GENRES in types) genreMatches(query) else emptyList()),
stations = if (DiscoverType.STATIONS in types)
stations.getAll().filter {
it.name.lowercase().contains(matchQ) ||
it.genre.lowercase().contains(matchQ)
}
else emptyList(),
)
_searchState.value =
if (bucket.isEmpty()) WavlakeSearchState.Error("No results for “$query”.")
else WavlakeSearchState.Results(bucket)
}
.launchIn(viewModelScope)
refreshRankings(null)
}
/** Genres matching the query: curated discover genres + every station genre. */
private fun genreMatches(query: String): List<String> {
val q = query.lowercase()
return (DISCOVER_GENRES + stations.getAll().map { it.genre }.filter { it.isNotBlank() })
.map { it to it.lowercase() }
.filter { (_, low) -> low.contains(q) || q.contains(low.substringBefore('/')) }
.map { it.first }
.distinct()
.take(8)
}
fun search(query: String) {
_searchQuery.value = query
}
@ -220,6 +308,31 @@ class WavlakeViewModel @Inject constructor(
)
}
/** Start a local/added radio station found through Discover search. */
fun playFoundStation(station: com.rada.domain.model.Station) {
playbackManager.playStation(station.url, station.name)
}
/** Jump the Stations tab filtered to this genre (Discover genre hit). */
fun openGenre(genre: String) {
_rankingsGenre.value = genre
viewModelScope.launch {
_rankingsLoading.value = true
_rankingsError.value = null
when (val r = repository.rankings(days = 7, genre = genre, limit = 25)) {
is WavlakeResult.Ok -> {
_rankings.value = r.value
resolveAddresses(r.value)
}
is WavlakeResult.Failed -> {
_rankings.value = emptyList()
_rankingsError.value = r.message
}
}
_rankingsLoading.value = false
}
}
fun saveTrack(track: WavlakeTrack) {
viewModelScope.launch {
_notice.value = when (val r = repository.importTrack(track)) {

View file

@ -1,5 +1,6 @@
package com.rada.ui.library
import androidx.compose.animation.Crossfade
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@ -28,6 +29,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.MusicNote
@ -107,6 +109,7 @@ fun LibraryScreen(
var showSearch by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
var showImportMenu by remember { mutableStateOf(false) }
var showMoreMenu by remember { mutableStateOf(false) }
var showAppearance by remember { mutableStateOf(false) }
var playlistToDelete by remember { mutableStateOf<PlaylistEntity?>(null) }
var songForPlaylist by remember { mutableStateOf<SavedSongEntity?>(null) }
@ -154,7 +157,14 @@ fun LibraryScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Library") },
title = {
Text(
"Library",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
)
},
navigationIcon = {
Row(horizontalArrangement = Arrangement.Start) {
AppHomeButton(onClick = onHome)
@ -197,45 +207,75 @@ fun LibraryScreen(
Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play")
}
}
IconButton(
onClick = { viewModel.refreshImportedFolders() },
enabled = !uiState.isImporting,
) {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh imported folders",
tint = if (uiState.isImporting) {
LocalTokens.current.textSecondary.copy(alpha = 0.4f)
} else {
LocalTokens.current.textSecondary
},
)
}
// Reliable delete path for the selected folder (besides long-press).
if (uiState.selectedPlaylistId != null) {
allPlaylists.firstOrNull { it.id == uiState.selectedPlaylistId }?.let { selected ->
IconButton(onClick = { playlistToDelete = selected }) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete folder",
tint = LocalTokens.current.textSecondary,
// Refresh / delete-folder / multi-select live in one
// overflow menu so the title keeps its full width instead
// of wrapping mid-word ("Libr|ary"). All still reachable.
Box {
IconButton(onClick = { showMoreMenu = true }) {
Icon(
Icons.Default.MoreVert,
contentDescription = "More library actions",
tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill
else LocalTokens.current.textSecondary,
)
}
DropdownMenu(
expanded = showMoreMenu,
onDismissRequest = { showMoreMenu = false },
) {
DropdownMenuItem(
text = { Text("Refresh imported folders") },
enabled = !uiState.isImporting,
onClick = {
showMoreMenu = false
viewModel.refreshImportedFolders()
},
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
)
if (uiState.selectedPlaylistId != null) {
allPlaylists.firstOrNull { it.id == uiState.selectedPlaylistId }
?.let { selected ->
DropdownMenuItem(
text = { Text("Delete “${selected.name}”") },
onClick = {
showMoreMenu = false
playlistToDelete = selected
},
leadingIcon = {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
)
}
}
if (tabLabels.size > 1) {
DropdownMenuItem(
text = {
Text(
if (uiState.isMultiSelect) "Exit multi-select"
else "Select multiple folders",
)
},
onClick = {
showMoreMenu = false
if (uiState.isMultiSelect) viewModel.exitMultiSelect()
else viewModel.enterMultiSelect()
},
leadingIcon = {
Icon(
Icons.Default.Checklist,
contentDescription = null,
tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill
else LocalTokens.current.textSecondary,
)
},
)
}
}
}
// Multi-select: combine several folders, shuffle the union.
if (tabLabels.size > 1) {
IconButton(onClick = {
if (uiState.isMultiSelect) viewModel.exitMultiSelect()
else viewModel.enterMultiSelect()
}) {
Icon(
Icons.Default.Checklist,
contentDescription = if (uiState.isMultiSelect) "Exit playlist selection" else "Select multiple playlists",
tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
)
}
}
IconButton(onClick = { showAppearance = true }) {
Icon(
Icons.Default.Settings,
@ -284,16 +324,20 @@ fun LibraryScreen(
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = 2.dp),
) {
// Crossfade the header when the selected folder changes — a
// hard text swap reads as a glitch when tabs are tapped.
Crossfade(targetState = headerTitle, label = "libraryHeader") { t ->
Text(
text = t,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = LocalTokens.current.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = headerTitle,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = LocalTokens.current.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${uiState.songs.size} songs",
text = "${uiState.songs.size} ${if (uiState.songs.size == 1) "song" else "songs"}",
style = MaterialTheme.typography.bodySmall,
color = LocalTokens.current.textSecondary,
)
@ -477,8 +521,12 @@ fun LibraryScreen(
modifier = Modifier.fillMaxSize(),
) {
items(uiState.songs, key = { it.id }) { song ->
// Keyed rows animate in/out when the folder tab or
// search filter changes instead of popping.
val rowMod = if (LocalReduceMotion.current) Modifier else Modifier.animateItem()
SongListItem(
song = song,
modifier = rowMod,
isPlaying = song.id == uiState.currentPlayingId,
onClick = { viewModel.playSong(song) },
onToggleFavorite = { viewModel.toggleFavorite(song.id) },
@ -768,10 +816,11 @@ private fun SongListItem(
onToggleFavorite: () -> Unit,
onAddToPlaylist: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
Row(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.pressDip(interaction, LocalReduceMotion.current)
.clickable(interactionSource = interaction, indication = androidx.compose.foundation.LocalIndication.current, onClick = onClick)

View file

@ -30,6 +30,7 @@ import com.rada.ui.player.NowPlayingBar
import com.rada.ui.playlists.PlaylistManagerScreen
import com.rada.ui.stations.StationBrowserScreen
import com.rada.ui.streams.StreamsScreen
import com.rada.ui.theme.LocalReduceMotion
import com.rada.ui.theme.LocalTokens
import kotlinx.coroutines.launch
@ -49,14 +50,26 @@ fun TabsScreen(
) {
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 }
val scope = rememberCoroutineScope()
val reduceMotion = LocalReduceMotion.current
// Deep-link target for Library (playlist picker → open that playlist).
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
// Tab taps land immediately. Swiping remains animated, but animating a
// tap composes and disposes heavy pages while the gesture is in flight.
// Adjacent tabs glide (neighbors stay composed, so it's smooth); far
// taps jump instantly — animating a multi-page hop composes heavy pages
// mid-flight and reintroduces the jank the jump was there to avoid.
fun goToPage(page: Int) {
if (pagerState.currentPage == page) return
scope.launch {
pagerState.scrollToPage(page)
if (kotlin.math.abs(pagerState.currentPage - page) == 1 && !reduceMotion) {
pagerState.animateScrollToPage(
page,
animationSpec = androidx.compose.animation.core.tween(
durationMillis = 260,
easing = com.rada.ui.theme.MotionArrival,
),
)
} else {
pagerState.scrollToPage(page)
}
}
}

View file

@ -84,6 +84,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@ -440,41 +441,49 @@ private fun WaveGraphic(modifier: Modifier = Modifier) {
val gradientColors = listOf(LocalTokens.current.accentFill, GradientMid, GradientEnd)
val accentGlow = LocalTokens.current.accentFill
Canvas(modifier = modifier) {
// Build each wave path + its gradient ONCE per size/palette, not per draw
// pass: this canvas sits on the hub, and rebuilding ~1k lineTo segments
// per layer was the heavy frame behind the hub-transition jank (99th-p
// ~200ms). 4px steps are indistinguishable at this frequency and cut
// segment count by 4x.
var canvasSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) }
val layers = remember(canvasSize, gradientColors) {
val w = canvasSize.width.toFloat()
val h = canvasSize.height.toFloat()
if (w <= 0f || h <= 0f) emptyList()
else (0..2).map { layer ->
val amplitude = (30f - layer * 8f) * (w / 400f)
val frequency = 0.008f + layer * 0.002f
val phaseShift = layer * 0.5f
val path = Path().apply {
moveTo(0f, h / 2 + amplitude * kotlin.math.sin(phaseShift.toDouble()).toFloat())
var x = 0f
while (x <= w) {
val y = h / 2 + amplitude * kotlin.math.sin(
(x * frequency * Math.PI * 2).toDouble() + phaseShift
).toFloat()
lineTo(x, y)
x += 4f
}
}
val strokeBrush = Brush.horizontalGradient(
colors = gradientColors.map { it.copy(alpha = 0.6f - layer * 0.15f) },
startX = 0f,
endX = w,
)
Triple(path, strokeBrush, (3f - layer * 0.5f) * (w / 400f))
}
}
Canvas(
modifier = modifier.onSizeChanged { canvasSize = it },
) {
val width = size.width
val height = size.height
val centerY = height / 2
// Draw multiple wave layers
for (layer in 0..2) {
val path = Path()
val amplitude = (30f - layer * 8f) * (width / 400f)
val frequency = 0.008f + layer * 0.002f
val phaseShift = layer * 0.5f
path.moveTo(0f, centerY)
for (x in 0..width.toInt()) {
val xFloat = x.toFloat()
val y = centerY + amplitude * kotlin.math.sin(
(xFloat * frequency * Math.PI * 2).toDouble() + phaseShift
).toFloat()
if (x == 0) {
path.moveTo(xFloat, y)
} else {
path.lineTo(xFloat, y)
}
}
drawPath(
path = path,
brush = Brush.horizontalGradient(
colors = gradientColors.map { it.copy(alpha = 0.6f - layer * 0.15f) },
startX = 0f,
endX = width,
),
style = Stroke(width = (3f - layer * 0.5f) * (width / 400f)),
)
for ((path, strokeBrush, strokeWidth) in layers) {
drawPath(path = path, brush = strokeBrush, style = Stroke(width = strokeWidth))
}
// Ambient glow behind the waves — wide, faint, no visible disc edge.

View file

@ -1,5 +1,8 @@
package com.rada.ui.theme
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@ -18,10 +21,17 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -30,6 +40,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rada.BuildConfig
import com.rada.update.UpdateChecker
import kotlinx.coroutines.launch
/** Static swatch preview colors for the picker (independent of active theme). */
internal val AccentSwatches = mapOf(
@ -52,6 +65,46 @@ fun AppearanceDialog(
val context = LocalContext.current
val tokens = LocalTokens.current
val lightMode = !MaterialTheme.colorScheme.background.luminanceIsDark()
val scope = rememberCoroutineScope()
// Update flow state: idle | checking | downloading(progress) | ready | error | upToDate
var updateStatus by remember { mutableStateOf<String?>(null) }
var updateProgress by remember { mutableFloatStateOf(-1f) }
var updateInfo by remember { mutableStateOf<UpdateChecker.UpdateInfo?>(null) }
fun startUpdate() {
val pm = context.packageManager
if (!pm.canRequestPackageInstalls()) {
updateStatus = "needs_permission"
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
.onFailure { updateStatus = "Can't open install settings on this device." }
return
}
updateStatus = "checking"
scope.launch {
try {
val info = UpdateChecker.check(BuildConfig.VERSION_NAME)
if (info == null) {
updateStatus = "up_to_date"
} else {
updateInfo = info
updateStatus = "downloading"
updateProgress = -1f
val apk = UpdateChecker.download(context, info.downloadUrl) { f ->
updateProgress = f
}
updateStatus = "ready"
UpdateChecker.launchInstall(context, apk)
}
} catch (e: Exception) {
updateStatus = "error: ${e.message ?: "download failed"}"
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
@ -141,6 +194,52 @@ fun AppearanceDialog(
fontSize = 12.sp,
color = tokens.textMuted,
)
Spacer(Modifier.height(22.dp))
Text(
"UPDATE",
fontSize = 12.sp,
letterSpacing = 0.4.sp,
fontWeight = FontWeight.SemiBold,
color = tokens.textMuted,
)
Spacer(Modifier.height(10.dp))
val busy = updateStatus == "checking" || updateStatus == "downloading"
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(SatsAmpGlass.rInput))
.background(tokens.accentSoft)
.border(1.dp, tokens.borderInteractive, RoundedCornerShape(SatsAmpGlass.rInput))
.clickable(enabled = !busy) { startUpdate() }
.padding(vertical = 12.dp, horizontal = 14.dp),
) {
Column {
Text(
when (updateStatus) {
"checking" -> "Checking for updates…"
"downloading" -> if (updateProgress >= 0f)
"Downloading v${updateInfo?.version}… ${(updateProgress * 100).toInt()}%"
else
"Downloading v${updateInfo?.version}…"
"ready" -> "Update downloaded — install again if the dialog was dismissed"
"up_to_date" -> "You're up to date (v${BuildConfig.VERSION_NAME})"
"needs_permission" -> "Allow \"Install unknown apps\" for SatsAmp, then tap again"
null -> "Check for updates (current: v${BuildConfig.VERSION_NAME})"
else -> updateStatus ?: ""
},
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = tokens.text,
)
if (updateStatus == "downloading" && updateProgress >= 0f) {
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { updateProgress },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
},
confirmButton = {

View file

@ -1,6 +1,7 @@
package com.rada.ui.theme
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.Composable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
@ -130,12 +131,15 @@ fun feedbackDuration(reduce: Boolean): Int = if (reduce) 0 else FeedbackMs
/**
* Press-dip for list rows: scales to 0.97 while pressed. Use on rows that
* already have a clickable/combinedClickable modifier earlier in the chain.
* (A plain @Composable extension — the old composed{} form spun up an extra
* subcomposition per row, wasted recomposition churn in long lists.)
*/
@Composable
fun Modifier.pressDip(
interactionSource: MutableInteractionSource,
reduce: Boolean,
): Modifier = composed {
if (reduce) return@composed this
): Modifier {
if (reduce) return this
val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.97f else 1f,
@ -145,7 +149,7 @@ fun Modifier.pressDip(
),
label = "pressDip",
)
this.graphicsLayer { scaleX = scale; scaleY = scale }
return this.graphicsLayer { scaleX = scale; scaleY = scale }
}
// ── Dismiss-by-drag ───────────────────────────────────────────────────────

View file

@ -0,0 +1,121 @@
package com.rada.update
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Self-update over the Forgejo release API (git.atitlan.io/avi/SatsAmp).
* Reads the latest public release anonymously, downloads the APK asset into
* the app cache, then hands it to the system installer via FileProvider —
* the user only needs to confirm the install dialog (and allow
* "install unknown apps" once).
*/
object UpdateChecker {
const val RELEASE_PAGE_URL = "https://git.atitlan.io/avi/SatsAmp/releases"
private const val LATEST_API =
"https://git.atitlan.io/api/v1/repos/avi/SatsAmp/releases/latest"
private val json = Json { ignoreUnknownKeys = true }
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
@Serializable
private data class ReleaseJson(
val tag_name: String = "",
val assets: List<AssetJson> = emptyList(),
)
@Serializable
private data class AssetJson(
val name: String = "",
val size: Long = 0,
val browser_download_url: String = "",
)
data class UpdateInfo(val version: String, val downloadUrl: String, val sizeBytes: Long)
/** Returns UpdateInfo when the latest release is newer than this build, else null. */
suspend fun check(currentVersion: String): UpdateInfo? = withContext(Dispatchers.IO) {
val req = Request.Builder().url(LATEST_API)
.header("Accept", "application/json").build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@withContext null // no releases yet / offline
val body = resp.body?.string() ?: return@withContext null
val rel = json.decodeFromString(ReleaseJson.serializer(), body)
val apk = rel.assets.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) }
?: return@withContext null
val v = normalize(rel.tag_name)
if (v.isNotEmpty() && compareVersions(v, normalize(currentVersion)) > 0)
UpdateInfo(v, apk.browser_download_url, apk.size)
else null
}
}
/** Downloads the APK; [onProgress] gets fraction 0..1 (or -1 if size unknown). */
suspend fun download(
context: Context,
url: String,
onProgress: (Float) -> Unit,
): File = withContext(Dispatchers.IO) {
val dir = File(context.cacheDir, "apks").apply { mkdirs() }
// Clear stale downloads so a partial/old file can't shadow the new one.
dir.listFiles()?.forEach { it.delete() }
val out = File(dir, "update.apk")
val req = Request.Builder().url(url).build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw java.io.IOException("Download failed: HTTP ${resp.code}")
val stream = resp.body?.byteStream() ?: throw java.io.IOException("Empty body")
val total = resp.body?.contentLength() ?: -1L
stream.use { input ->
out.outputStream().use { os ->
val buf = ByteArray(64 * 1024)
var done = 0L
while (true) {
val n = input.read(buf)
if (n < 0) break
os.write(buf, 0, n)
done += n
onProgress(if (total > 0) done.toFloat() / total else -1f)
}
}
}
}
out
}
/** Launches the system package installer for the downloaded APK. */
fun launchInstall(context: Context, apk: File) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
/** "v0.1.2" / "0.1.2" -> "0.1.2" */
private fun normalize(v: String): String =
v.trim().removePrefix("v").removePrefix("V").substringBefore('-')
/** Component-wise numeric compare; missing components count as 0. */
internal fun compareVersions(a: String, b: String): Int {
val pa = a.split('.').map { it.toIntOrNull() ?: 0 }
val pb = b.split('.').map { it.toIntOrNull() ?: 0 }
for (i in 0 until maxOf(pa.size, pb.size)) {
val d = (pa.getOrNull(i) ?: 0) - (pb.getOrNull(i) ?: 0)
if (d != 0) return if (d > 0) 1 else -1
}
return 0
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 B

After

Width:  |  Height:  |  Size: 7 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 B

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 707 B

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 975 B

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Update APKs downloaded into context.cacheDir/apks -->
<cache-path name="apk" path="apks/" />
</paths>

View file

@ -0,0 +1,23 @@
package com.rada.update
import org.junit.Assert.assertEquals
import org.junit.Test
class UpdateCheckerTest {
@Test fun `newer release detected`() {
assertEquals(1, UpdateChecker.compareVersions("0.1.1", "0.1.0"))
assertEquals(1, UpdateChecker.compareVersions("0.2.0", "0.1.9"))
assertEquals(1, UpdateChecker.compareVersions("1.0.0", "0.9.9"))
}
@Test fun `same and older are not updates`() {
assertEquals(0, UpdateChecker.compareVersions("0.1.0", "0.1.0"))
assertEquals(-1, UpdateChecker.compareVersions("0.1.0", "0.1.1"))
assertEquals(-1, UpdateChecker.compareVersions("0.9", "0.10"))
}
@Test fun `missing components count as zero`() {
assertEquals(0, UpdateChecker.compareVersions("0.1", "0.1.0"))
assertEquals(1, UpdateChecker.compareVersions("0.1.1", "0.1"))
}
}