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 {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
buildConfig = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<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" />
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
|
||||||
<!-- NIP-55 external signer discovery (Amber, etc.) -->
|
<!-- NIP-55 external signer discovery (Amber, etc.) -->
|
||||||
|
|
@ -50,17 +48,6 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -465,18 +465,11 @@ class PlaybackManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startIcyReader(url: String) {
|
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()
|
val reader = IcyStreamReader()
|
||||||
icyReader = reader
|
icyReader = reader
|
||||||
|
|
||||||
reader.listener = object : IcyStreamReader.Listener {
|
reader.listener = object : IcyStreamReader.Listener {
|
||||||
override fun onMetadata(metadata: StreamMetadata, audioBytesAtMetadata: Long) {
|
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")
|
Log.d(TAG, "ICY metadata: ${metadata.artist} - ${metadata.title} @ $audioBytesAtMetadata")
|
||||||
// Record at the reader-reported ABSOLUTE audio position (the same
|
// Record at the reader-reported ABSOLUTE audio position (the same
|
||||||
// quantity as audioBuffer.totalBytes). The previous code read
|
// quantity as audioBuffer.totalBytes). The previous code read
|
||||||
|
|
@ -493,7 +486,6 @@ class PlaybackManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAudioBytes(data: ByteArray, offset: Int, length: Long) {
|
override fun onAudioBytes(data: ByteArray, offset: Int, length: Long) {
|
||||||
if (icyReader !== reader) return
|
|
||||||
audioBuffer?.write(data, offset, length.toInt())
|
audioBuffer?.write(data, offset, length.toInt())
|
||||||
listener?.onBufferBytesWritten(audioBuffer?.totalBytes ?: 0)
|
listener?.onBufferBytesWritten(audioBuffer?.totalBytes ?: 0)
|
||||||
|
|
||||||
|
|
@ -503,7 +495,6 @@ class PlaybackManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(error: String) {
|
override fun onError(error: String) {
|
||||||
if (icyReader !== reader) return
|
|
||||||
Log.w(TAG, "ICY reader error: $error")
|
Log.w(TAG, "ICY reader error: $error")
|
||||||
// ExoPlayer often rides out a transient drop on its own
|
// ExoPlayer often rides out a transient drop on its own
|
||||||
// buffer without erroring; if it does, the recorder would
|
// buffer without erroring; if it does, the recorder would
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,6 @@ class IcyStreamReader(
|
||||||
var isRunning: Boolean = false
|
var isRunning: Boolean = false
|
||||||
private set
|
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 metaInterval: Int = 0
|
||||||
private var bytesInInterval: Int = 0
|
private var bytesInInterval: Int = 0
|
||||||
private var pendingMetaBytes: Int = 0
|
private var pendingMetaBytes: Int = 0
|
||||||
|
|
@ -58,7 +52,6 @@ class IcyStreamReader(
|
||||||
fun start(streamUrl: String) {
|
fun start(streamUrl: String) {
|
||||||
if (isRunning) stop()
|
if (isRunning) stop()
|
||||||
|
|
||||||
requestedStop = false
|
|
||||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
scope?.launch {
|
scope?.launch {
|
||||||
try {
|
try {
|
||||||
|
|
@ -66,10 +59,8 @@ class IcyStreamReader(
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
// Normal stop
|
// Normal stop
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (!requestedStop) {
|
|
||||||
Log.e(TAG, "Error reading stream", e)
|
Log.e(TAG, "Error reading stream", e)
|
||||||
listener?.onError(e.message ?: "Connection error")
|
listener?.onError(e.message ?: "Connection error")
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
isRunning = false
|
isRunning = false
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +68,6 @@ class IcyStreamReader(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
requestedStop = true
|
|
||||||
scope?.cancel()
|
scope?.cancel()
|
||||||
scope = null
|
scope = null
|
||||||
closeConnection()
|
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.Add
|
||||||
import androidx.compose.material.icons.filled.Bolt
|
import androidx.compose.material.icons.filled.Bolt
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
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.Search
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.AlertDialog
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.rada.data.wavlake.MatchingMode
|
import com.rada.data.wavlake.MatchingMode
|
||||||
import com.rada.data.wavlake.WavlakeSearchHit
|
import com.rada.data.wavlake.WavlakeSearchHit
|
||||||
|
|
@ -206,25 +204,6 @@ fun DiscoverScreen(
|
||||||
shape = RoundedCornerShape(12.dp),
|
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) {
|
when (val s = searchState) {
|
||||||
WavlakeSearchState.Idle -> {
|
WavlakeSearchState.Idle -> {
|
||||||
GenreChips(
|
GenreChips(
|
||||||
|
|
@ -268,142 +247,33 @@ fun DiscoverScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is WavlakeSearchState.Results -> {
|
is WavlakeSearchState.Results -> {
|
||||||
val r = s.results
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
if (r.stations.isNotEmpty()) {
|
items(s.hits, key = { it.stableId() }, contentType = { "hit" }) { hit ->
|
||||||
item(key = "sec-stations", contentType = "header") {
|
when (hit) {
|
||||||
SectionHeader("Stations")
|
is WavlakeSearchHit.Artist -> ArtistRow(
|
||||||
}
|
name = hit.artist.name,
|
||||||
items(r.stations, key = { "st-${it.id}" }, contentType = { "station" }) { st ->
|
genre = hit.artist.genre,
|
||||||
Card(
|
imageUrl = hit.artist.imageUrl,
|
||||||
onClick = { viewModel.playFoundStation(st) },
|
artistId = hit.artist.id,
|
||||||
modifier = Modifier
|
address = addresses[hit.artist.id],
|
||||||
.fillMaxWidth()
|
onClick = {
|
||||||
.padding(horizontal = 12.dp),
|
viewModel.openArtist(hit.artist.id, hit.artist.name)
|
||||||
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 = {
|
onZap = {
|
||||||
viewModel.resolveArtistAddress(artist.id)
|
viewModel.resolveArtistAddress(hit.artist.id)
|
||||||
pendingZapArtistId = artist.id
|
pendingZapArtistId = hit.artist.id
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
is WavlakeSearchHit.Album -> AlbumRow(
|
||||||
}
|
title = hit.title,
|
||||||
if (r.tracks.isNotEmpty() || r.albums.isNotEmpty()) {
|
artist = hit.artist,
|
||||||
item(key = "sec-tracks", contentType = "header") {
|
artworkUrl = hit.artworkUrl,
|
||||||
SectionHeader("Tracks")
|
onClick = { /* albums open via artist detail */ },
|
||||||
}
|
)
|
||||||
items(r.tracks, key = { "track-${it.track.id}" }, contentType = { "track" }) { hit ->
|
is WavlakeSearchHit.Track -> TrackRow(
|
||||||
TrackRow(
|
|
||||||
track = com.rada.domain.model.WavlakeTrack(
|
track = com.rada.domain.model.WavlakeTrack(
|
||||||
id = hit.track.id,
|
id = hit.track.id,
|
||||||
title = hit.track.title,
|
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(
|
private data class ZapTarget(
|
||||||
val artistId: String,
|
val artistId: String,
|
||||||
val artistName: String,
|
val artistName: String,
|
||||||
|
|
|
||||||
|
|
@ -26,27 +26,10 @@ import javax.inject.Inject
|
||||||
/** Curated genre shortcuts — exact Wavlake catalog names only. */
|
/** Curated genre shortcuts — exact Wavlake catalog names only. */
|
||||||
val DISCOVER_GENRES = listOf("Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock")
|
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 {
|
sealed interface WavlakeSearchState {
|
||||||
data object Idle : WavlakeSearchState
|
data object Idle : WavlakeSearchState
|
||||||
data object Loading : 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
|
data class Error(val message: String) : WavlakeSearchState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,39 +39,11 @@ class WavlakeViewModel @Inject constructor(
|
||||||
private val repository: WavlakeRepository,
|
private val repository: WavlakeRepository,
|
||||||
private val playbackManager: PlaybackManager,
|
private val playbackManager: PlaybackManager,
|
||||||
private val playlists: PlaylistRepository,
|
private val playlists: PlaylistRepository,
|
||||||
private val stations: com.rada.data.repository.StationRepository,
|
|
||||||
@dagger.hilt.android.qualifiers.ApplicationContext private val appContext: android.content.Context,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _searchQuery = MutableStateFlow("")
|
private val _searchQuery = MutableStateFlow("")
|
||||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
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)
|
private val _searchState = MutableStateFlow<WavlakeSearchState>(WavlakeSearchState.Idle)
|
||||||
val searchState: StateFlow<WavlakeSearchState> = _searchState.asStateFlow()
|
val searchState: StateFlow<WavlakeSearchState> = _searchState.asStateFlow()
|
||||||
|
|
||||||
|
|
@ -134,60 +89,17 @@ class WavlakeViewModel @Inject constructor(
|
||||||
return@onEach
|
return@onEach
|
||||||
}
|
}
|
||||||
_searchState.value = WavlakeSearchState.Loading
|
_searchState.value = WavlakeSearchState.Loading
|
||||||
val types = _selectedTypes.value
|
_searchState.value = when (val r = repository.search(query)) {
|
||||||
// Local stations need no network; Wavlake hit types share one call.
|
is WavlakeResult.Ok ->
|
||||||
val needRemote = types.any {
|
if (r.value.isEmpty()) WavlakeSearchState.Error("No Wavlake results.")
|
||||||
it != DiscoverType.GENRES && it != DiscoverType.STATIONS
|
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)
|
.launchIn(viewModelScope)
|
||||||
refreshRankings(null)
|
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) {
|
fun search(query: String) {
|
||||||
_searchQuery.value = query
|
_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) {
|
fun saveTrack(track: WavlakeTrack) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_notice.value = when (val r = repository.importTrack(track)) {
|
_notice.value = when (val r = repository.importTrack(track)) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.rada.ui.library
|
package com.rada.ui.library
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
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.Checklist
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
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.Favorite
|
||||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||||
import androidx.compose.material.icons.filled.MusicNote
|
import androidx.compose.material.icons.filled.MusicNote
|
||||||
|
|
@ -109,7 +107,6 @@ fun LibraryScreen(
|
||||||
var showSearch by remember { mutableStateOf(false) }
|
var showSearch by remember { mutableStateOf(false) }
|
||||||
var searchQuery by remember { mutableStateOf("") }
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
var showImportMenu by remember { mutableStateOf(false) }
|
var showImportMenu by remember { mutableStateOf(false) }
|
||||||
var showMoreMenu by remember { mutableStateOf(false) }
|
|
||||||
var showAppearance by remember { mutableStateOf(false) }
|
var showAppearance by remember { mutableStateOf(false) }
|
||||||
var playlistToDelete by remember { mutableStateOf<PlaylistEntity?>(null) }
|
var playlistToDelete by remember { mutableStateOf<PlaylistEntity?>(null) }
|
||||||
var songForPlaylist by remember { mutableStateOf<SavedSongEntity?>(null) }
|
var songForPlaylist by remember { mutableStateOf<SavedSongEntity?>(null) }
|
||||||
|
|
@ -157,14 +154,7 @@ fun LibraryScreen(
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = { Text("Library") },
|
||||||
Text(
|
|
||||||
"Library",
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
Row(horizontalArrangement = Arrangement.Start) {
|
Row(horizontalArrangement = Arrangement.Start) {
|
||||||
AppHomeButton(onClick = onHome)
|
AppHomeButton(onClick = onHome)
|
||||||
|
|
@ -207,73 +197,43 @@ fun LibraryScreen(
|
||||||
Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play")
|
Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Refresh / delete-folder / multi-select live in one
|
IconButton(
|
||||||
// overflow menu so the title keeps its full width instead
|
onClick = { viewModel.refreshImportedFolders() },
|
||||||
// of wrapping mid-word ("Libr|ary"). All still reachable.
|
enabled = !uiState.isImporting,
|
||||||
Box {
|
) {
|
||||||
IconButton(onClick = { showMoreMenu = true }) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.MoreVert,
|
Icons.Default.Refresh,
|
||||||
contentDescription = "More library actions",
|
contentDescription = "Refresh imported folders",
|
||||||
tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill
|
tint = if (uiState.isImporting) {
|
||||||
else LocalTokens.current.textSecondary,
|
LocalTokens.current.textSecondary.copy(alpha = 0.4f)
|
||||||
|
} else {
|
||||||
|
LocalTokens.current.textSecondary
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenu(
|
// Reliable delete path for the selected folder (besides long-press).
|
||||||
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) {
|
if (uiState.selectedPlaylistId != null) {
|
||||||
allPlaylists.firstOrNull { it.id == uiState.selectedPlaylistId }
|
allPlaylists.firstOrNull { it.id == uiState.selectedPlaylistId }?.let { selected ->
|
||||||
?.let { selected ->
|
IconButton(onClick = { playlistToDelete = selected }) {
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Delete “${selected.name}”") },
|
|
||||||
onClick = {
|
|
||||||
showMoreMenu = false
|
|
||||||
playlistToDelete = selected
|
|
||||||
},
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Delete,
|
Icons.Default.Delete,
|
||||||
contentDescription = null,
|
contentDescription = "Delete folder",
|
||||||
tint = MaterialTheme.colorScheme.error,
|
tint = LocalTokens.current.textSecondary,
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Multi-select: combine several folders, shuffle the union.
|
||||||
if (tabLabels.size > 1) {
|
if (tabLabels.size > 1) {
|
||||||
DropdownMenuItem(
|
IconButton(onClick = {
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
if (uiState.isMultiSelect) "Exit multi-select"
|
|
||||||
else "Select multiple folders",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
showMoreMenu = false
|
|
||||||
if (uiState.isMultiSelect) viewModel.exitMultiSelect()
|
if (uiState.isMultiSelect) viewModel.exitMultiSelect()
|
||||||
else viewModel.enterMultiSelect()
|
else viewModel.enterMultiSelect()
|
||||||
},
|
}) {
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Checklist,
|
Icons.Default.Checklist,
|
||||||
contentDescription = null,
|
contentDescription = if (uiState.isMultiSelect) "Exit playlist selection" else "Select multiple playlists",
|
||||||
tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill
|
tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
|
||||||
else LocalTokens.current.textSecondary,
|
|
||||||
)
|
)
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = { showAppearance = true }) {
|
IconButton(onClick = { showAppearance = true }) {
|
||||||
|
|
@ -324,20 +284,16 @@ fun LibraryScreen(
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.padding(top = 4.dp, bottom = 2.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(
|
||||||
text = t,
|
text = headerTitle,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = LocalTokens.current.text,
|
color = LocalTokens.current.text,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = "${uiState.songs.size} ${if (uiState.songs.size == 1) "song" else "songs"}",
|
text = "${uiState.songs.size} songs",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = LocalTokens.current.textSecondary,
|
color = LocalTokens.current.textSecondary,
|
||||||
)
|
)
|
||||||
|
|
@ -521,12 +477,8 @@ fun LibraryScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
items(uiState.songs, key = { it.id }) { song ->
|
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(
|
SongListItem(
|
||||||
song = song,
|
song = song,
|
||||||
modifier = rowMod,
|
|
||||||
isPlaying = song.id == uiState.currentPlayingId,
|
isPlaying = song.id == uiState.currentPlayingId,
|
||||||
onClick = { viewModel.playSong(song) },
|
onClick = { viewModel.playSong(song) },
|
||||||
onToggleFavorite = { viewModel.toggleFavorite(song.id) },
|
onToggleFavorite = { viewModel.toggleFavorite(song.id) },
|
||||||
|
|
@ -816,11 +768,10 @@ private fun SongListItem(
|
||||||
onToggleFavorite: () -> Unit,
|
onToggleFavorite: () -> Unit,
|
||||||
onAddToPlaylist: () -> Unit,
|
onAddToPlaylist: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
val interaction = remember { MutableInteractionSource() }
|
val interaction = remember { MutableInteractionSource() }
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.pressDip(interaction, LocalReduceMotion.current)
|
.pressDip(interaction, LocalReduceMotion.current)
|
||||||
.clickable(interactionSource = interaction, indication = androidx.compose.foundation.LocalIndication.current, onClick = onClick)
|
.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.playlists.PlaylistManagerScreen
|
||||||
import com.rada.ui.stations.StationBrowserScreen
|
import com.rada.ui.stations.StationBrowserScreen
|
||||||
import com.rada.ui.streams.StreamsScreen
|
import com.rada.ui.streams.StreamsScreen
|
||||||
import com.rada.ui.theme.LocalReduceMotion
|
|
||||||
import com.rada.ui.theme.LocalTokens
|
import com.rada.ui.theme.LocalTokens
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -50,28 +49,16 @@ fun TabsScreen(
|
||||||
) {
|
) {
|
||||||
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 }
|
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val reduceMotion = LocalReduceMotion.current
|
|
||||||
// Deep-link target for Library (playlist picker → open that playlist).
|
// Deep-link target for Library (playlist picker → open that playlist).
|
||||||
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
|
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
|
||||||
// Adjacent tabs glide (neighbors stay composed, so it's smooth); far
|
// Tab taps land immediately. Swiping remains animated, but animating a
|
||||||
// taps jump instantly — animating a multi-page hop composes heavy pages
|
// tap composes and disposes heavy pages while the gesture is in flight.
|
||||||
// mid-flight and reintroduces the jank the jump was there to avoid.
|
|
||||||
fun goToPage(page: Int) {
|
fun goToPage(page: Int) {
|
||||||
if (pagerState.currentPage == page) return
|
if (pagerState.currentPage == page) return
|
||||||
scope.launch {
|
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)
|
pagerState.scrollToPage(page)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -441,49 +440,41 @@ private fun WaveGraphic(modifier: Modifier = Modifier) {
|
||||||
val gradientColors = listOf(LocalTokens.current.accentFill, GradientMid, GradientEnd)
|
val gradientColors = listOf(LocalTokens.current.accentFill, GradientMid, GradientEnd)
|
||||||
val accentGlow = LocalTokens.current.accentFill
|
val accentGlow = LocalTokens.current.accentFill
|
||||||
|
|
||||||
// Build each wave path + its gradient ONCE per size/palette, not per draw
|
Canvas(modifier = modifier) {
|
||||||
// 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 width = size.width
|
||||||
val height = size.height
|
val height = size.height
|
||||||
val centerY = height / 2
|
val centerY = height / 2
|
||||||
|
|
||||||
for ((path, strokeBrush, strokeWidth) in layers) {
|
// Draw multiple wave layers
|
||||||
drawPath(path = path, brush = strokeBrush, style = Stroke(width = strokeWidth))
|
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.
|
// Ambient glow behind the waves — wide, faint, no visible disc edge.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
package com.rada.ui.theme
|
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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
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.material.icons.filled.Check
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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). */
|
/** Static swatch preview colors for the picker (independent of active theme). */
|
||||||
internal val AccentSwatches = mapOf(
|
internal val AccentSwatches = mapOf(
|
||||||
|
|
@ -65,46 +52,6 @@ fun AppearanceDialog(
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val tokens = LocalTokens.current
|
val tokens = LocalTokens.current
|
||||||
val lightMode = !MaterialTheme.colorScheme.background.luminanceIsDark()
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
|
|
@ -194,52 +141,6 @@ fun AppearanceDialog(
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = tokens.textMuted,
|
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 = {
|
confirmButton = {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.rada.ui.theme
|
package com.rada.ui.theme
|
||||||
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
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
|
* 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.
|
* 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(
|
fun Modifier.pressDip(
|
||||||
interactionSource: MutableInteractionSource,
|
interactionSource: MutableInteractionSource,
|
||||||
reduce: Boolean,
|
reduce: Boolean,
|
||||||
): Modifier {
|
): Modifier = composed {
|
||||||
if (reduce) return this
|
if (reduce) return@composed this
|
||||||
val pressed by interactionSource.collectIsPressedAsState()
|
val pressed by interactionSource.collectIsPressedAsState()
|
||||||
val scale by animateFloatAsState(
|
val scale by animateFloatAsState(
|
||||||
targetValue = if (pressed) 0.97f else 1f,
|
targetValue = if (pressed) 0.97f else 1f,
|
||||||
|
|
@ -149,7 +145,7 @@ fun Modifier.pressDip(
|
||||||
),
|
),
|
||||||
label = "pressDip",
|
label = "pressDip",
|
||||||
)
|
)
|
||||||
return this.graphicsLayer { scaleX = scale; scaleY = scale }
|
this.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dismiss-by-drag ───────────────────────────────────────────────────────
|
// ── 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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||