Compare commits
No commits in common. "b18f2df1f4d57b31bdac38cf08aae6f83047669b" and "36f61ddb40ce775a74b93eb9cb05d8b9cccef955" have entirely different histories.
b18f2df1f4
...
36f61ddb40
BIN
Satsamp Icon.png
|
Before Width: | Height: | Size: 960 KiB |
|
|
@ -54,7 +54,6 @@ android {
|
|||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@
|
|||
<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.) -->
|
||||
|
|
@ -50,17 +48,6 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -465,18 +465,11 @@ 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
|
||||
|
|
@ -493,7 +486,6 @@ 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)
|
||||
|
||||
|
|
@ -503,7 +495,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -18,12 +18,6 @@ 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
|
||||
|
|
@ -58,7 +52,6 @@ class IcyStreamReader(
|
|||
fun start(streamUrl: String) {
|
||||
if (isRunning) stop()
|
||||
|
||||
requestedStop = false
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
scope?.launch {
|
||||
try {
|
||||
|
|
@ -66,10 +59,8 @@ class IcyStreamReader(
|
|||
} catch (e: CancellationException) {
|
||||
// Normal stop
|
||||
} catch (e: Exception) {
|
||||
if (!requestedStop) {
|
||||
Log.e(TAG, "Error reading stream", e)
|
||||
listener?.onError(e.message ?: "Connection error")
|
||||
}
|
||||
} finally {
|
||||
isRunning = false
|
||||
}
|
||||
|
|
@ -77,7 +68,6 @@ class IcyStreamReader(
|
|||
}
|
||||
|
||||
fun stop() {
|
||||
requestedStop = true
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
closeConnection()
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ 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
|
||||
|
|
@ -62,7 +61,6 @@ 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
|
||||
|
|
@ -206,25 +204,6 @@ 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(
|
||||
|
|
@ -268,142 +247,33 @@ fun DiscoverScreen(
|
|||
}
|
||||
}
|
||||
is WavlakeSearchState.Results -> {
|
||||
val r = s.results
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
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) },
|
||||
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)
|
||||
},
|
||||
onZap = {
|
||||
viewModel.resolveArtistAddress(artist.id)
|
||||
pendingZapArtistId = artist.id
|
||||
viewModel.resolveArtistAddress(hit.artist.id)
|
||||
pendingZapArtistId = hit.artist.id
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
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(
|
||||
is WavlakeSearchHit.Album -> AlbumRow(
|
||||
title = hit.title,
|
||||
artist = hit.artist,
|
||||
artworkUrl = hit.artworkUrl,
|
||||
onClick = { /* albums open via artist detail */ },
|
||||
)
|
||||
is WavlakeSearchHit.Track -> TrackRow(
|
||||
track = com.rada.domain.model.WavlakeTrack(
|
||||
id = hit.track.id,
|
||||
title = hit.track.title,
|
||||
|
|
@ -447,14 +317,6 @@ 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 */ },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -472,18 +334,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -26,27 +26,10 @@ 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 results: DiscoverResults) : WavlakeSearchState
|
||||
data class Results(val hits: List<WavlakeSearchHit>) : WavlakeSearchState
|
||||
data class Error(val message: String) : WavlakeSearchState
|
||||
}
|
||||
|
||||
|
|
@ -56,39 +39,11 @@ 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()
|
||||
|
||||
|
|
@ -134,60 +89,17 @@ class WavlakeViewModel @Inject constructor(
|
|||
return@onEach
|
||||
}
|
||||
_searchState.value = WavlakeSearchState.Loading
|
||||
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
|
||||
_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 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
|
||||
}
|
||||
|
|
@ -308,31 +220,6 @@ 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)) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
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
|
||||
|
|
@ -29,7 +28,6 @@ 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
|
||||
|
|
@ -109,7 +107,6 @@ 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) }
|
||||
|
|
@ -157,14 +154,7 @@ fun LibraryScreen(
|
|||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"Library",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
},
|
||||
title = { Text("Library") },
|
||||
navigationIcon = {
|
||||
Row(horizontalArrangement = Arrangement.Start) {
|
||||
AppHomeButton(onClick = onHome)
|
||||
|
|
@ -207,73 +197,43 @@ fun LibraryScreen(
|
|||
Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play")
|
||||
}
|
||||
}
|
||||
// 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 }) {
|
||||
IconButton(
|
||||
onClick = { viewModel.refreshImportedFolders() },
|
||||
enabled = !uiState.isImporting,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = "More library actions",
|
||||
tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill
|
||||
else LocalTokens.current.textSecondary,
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = "Refresh imported folders",
|
||||
tint = if (uiState.isImporting) {
|
||||
LocalTokens.current.textSecondary.copy(alpha = 0.4f)
|
||||
} 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) },
|
||||
)
|
||||
// Reliable delete path for the selected folder (besides long-press).
|
||||
if (uiState.selectedPlaylistId != null) {
|
||||
allPlaylists.firstOrNull { it.id == uiState.selectedPlaylistId }
|
||||
?.let { selected ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete “${selected.name}”") },
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
playlistToDelete = selected
|
||||
},
|
||||
leadingIcon = {
|
||||
allPlaylists.firstOrNull { it.id == uiState.selectedPlaylistId }?.let { selected ->
|
||||
IconButton(onClick = { playlistToDelete = selected }) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
contentDescription = "Delete folder",
|
||||
tint = LocalTokens.current.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Multi-select: combine several folders, shuffle the union.
|
||||
if (tabLabels.size > 1) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
if (uiState.isMultiSelect) "Exit multi-select"
|
||||
else "Select multiple folders",
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
IconButton(onClick = {
|
||||
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,
|
||||
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 }) {
|
||||
|
|
@ -324,20 +284,16 @@ 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,
|
||||
text = headerTitle,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = LocalTokens.current.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "${uiState.songs.size} ${if (uiState.songs.size == 1) "song" else "songs"}",
|
||||
text = "${uiState.songs.size} songs",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = LocalTokens.current.textSecondary,
|
||||
)
|
||||
|
|
@ -521,12 +477,8 @@ 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) },
|
||||
|
|
@ -816,11 +768,10 @@ 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)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ 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
|
||||
|
||||
|
|
@ -50,28 +49,16 @@ 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) }
|
||||
// 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.
|
||||
// Tab taps land immediately. Swiping remains animated, but animating a
|
||||
// tap composes and disposes heavy pages while the gesture is in flight.
|
||||
fun goToPage(page: Int) {
|
||||
if (pagerState.currentPage == page) return
|
||||
scope.launch {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@ 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
|
||||
|
|
@ -441,49 +440,41 @@ private fun WaveGraphic(modifier: Modifier = Modifier) {
|
|||
val gradientColors = listOf(LocalTokens.current.accentFill, GradientMid, GradientEnd)
|
||||
val accentGlow = LocalTokens.current.accentFill
|
||||
|
||||
// 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 },
|
||||
) {
|
||||
Canvas(modifier = modifier) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val centerY = height / 2
|
||||
|
||||
for ((path, strokeBrush, strokeWidth) in layers) {
|
||||
drawPath(path = path, brush = strokeBrush, style = Stroke(width = strokeWidth))
|
||||
// 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)),
|
||||
)
|
||||
}
|
||||
|
||||
// Ambient glow behind the waves — wide, faint, no visible disc edge.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
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
|
||||
|
|
@ -21,17 +18,10 @@ 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
|
||||
|
|
@ -40,9 +30,6 @@ 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(
|
||||
|
|
@ -65,46 +52,6 @@ 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,
|
||||
|
|
@ -194,52 +141,6 @@ 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 = {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
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
|
||||
|
|
@ -131,15 +130,12 @@ 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 {
|
||||
if (reduce) return this
|
||||
): Modifier = composed {
|
||||
if (reduce) return@composed this
|
||||
val pressed by interactionSource.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (pressed) 0.97f else 1f,
|
||||
|
|
@ -149,7 +145,7 @@ fun Modifier.pressDip(
|
|||
),
|
||||
label = "pressDip",
|
||||
)
|
||||
return this.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
this.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
}
|
||||
|
||||
// ── Dismiss-by-drag ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,121 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7 KiB After Width: | Height: | Size: 379 B |
|
Before Width: | Height: | Size: 7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 264 B |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 485 B |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 707 B |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 975 B |
|
Before Width: | Height: | Size: 37 KiB |
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- Update APKs downloaded into context.cacheDir/apks -->
|
||||
<cache-path name="apk" path="apks/" />
|
||||
</paths>
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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"))
|
||||
}
|
||||
}
|
||||