Checkpoint all work; ignore build artifacts and gradle caches
This commit is contained in:
parent
b000544d15
commit
36f61ddb40
2443 changed files with 19745 additions and 51933 deletions
|
|
@ -7,6 +7,8 @@
|
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<!-- NIP-55 external signer discovery (Amber, etc.) -->
|
||||
<queries>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ class SongImporter @Inject constructor(
|
|||
private val playlistRepository: PlaylistRepository,
|
||||
) {
|
||||
|
||||
private val folderPrefs = context.getSharedPreferences("library_import_folders", Context.MODE_PRIVATE)
|
||||
|
||||
/** Entry point. Each uri may be a single document or a picked tree/folder. */
|
||||
suspend fun import(uris: List<Uri>): ImportResult {
|
||||
var imported = 0
|
||||
|
|
@ -43,6 +45,9 @@ class SongImporter @Inject constructor(
|
|||
val errors = mutableListOf<String>()
|
||||
|
||||
for (uri in uris) {
|
||||
if (uri.path?.startsWith("/tree/") == true) {
|
||||
persistFolderPermission(uri)
|
||||
}
|
||||
// Folder picks arrive as tree URIs (…/tree/primary:…); file picks
|
||||
// arrive as document URIs. fromSingleUri() misreads tree URIs as
|
||||
// files, so detect trees by their path first.
|
||||
|
|
@ -61,6 +66,7 @@ class SongImporter @Inject constructor(
|
|||
doc == null -> Log.w(TAG, "Cannot open picked tree: $uri")
|
||||
doc.isDirectory -> {
|
||||
val folderName = doc.name?.takeIf { it.isNotBlank() } ?: "Imported folder"
|
||||
folderPrefs.edit().putString(folderName, uri.toString()).apply()
|
||||
val audio = mutableListOf<Uri>()
|
||||
collectAudioFromTree(doc, audio)
|
||||
if (audio.isEmpty()) {
|
||||
|
|
@ -80,6 +86,26 @@ class SongImporter @Inject constructor(
|
|||
return ImportResult(imported, skipped, folders, errors)
|
||||
}
|
||||
|
||||
/** Re-scans every folder previously imported through the system folder picker. */
|
||||
suspend fun refreshImportedFolders(): ImportResult {
|
||||
val uris = folderPrefs.all.values.mapNotNull { value ->
|
||||
(value as? String)?.let(Uri::parse)
|
||||
}
|
||||
if (uris.isEmpty()) {
|
||||
return ImportResult(0, 0, errors = listOf("No imported folders to refresh"))
|
||||
}
|
||||
return import(uris)
|
||||
}
|
||||
|
||||
private fun persistFolderPermission(uri: Uri) {
|
||||
runCatching {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectAudioFromTree(dir: DocumentFile, out: MutableList<Uri>) {
|
||||
try {
|
||||
for (child in dir.listFiles()) {
|
||||
|
|
|
|||
|
|
@ -278,7 +278,13 @@ class ZapService @Inject constructor(
|
|||
)
|
||||
WavlakeZapOutcome.Sent
|
||||
}
|
||||
is PaymentResult.Failed -> WavlakeZapOutcome.Failed(pay.userFacing)
|
||||
is PaymentResult.Failed -> {
|
||||
// The invoice is still valid when an in-app wallet cannot pay
|
||||
// it (for example, an external signer cannot decrypt NWC).
|
||||
// Preserve it so the user can finish the zap in any wallet.
|
||||
Log.w("Zap", "In-app payment failed: ${pay.reason}")
|
||||
WavlakeZapOutcome.Invoice(invoice.bolt11, sats, invoice.requestId)
|
||||
}
|
||||
else -> WavlakeZapOutcome.Failed("Unexpected wallet response.")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||
import dagger.hilt.android.EntryPointAccessors
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
|
|
@ -48,16 +49,17 @@ class MainActivity : ComponentActivity() {
|
|||
enableEdgeToEdge()
|
||||
playbackManager.connect()
|
||||
askNotificationPermissionOnce()
|
||||
warmRepositories()
|
||||
|
||||
setContent {
|
||||
SatsAmpTheme {
|
||||
GlassBackground {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = Color.Transparent,
|
||||
) {
|
||||
NavGraph(navController = rememberNavController())
|
||||
lifecycleScope.launch {
|
||||
warmRepositories()
|
||||
setContent {
|
||||
SatsAmpTheme {
|
||||
GlassBackground {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = Color.Transparent,
|
||||
) {
|
||||
NavGraph(navController = rememberNavController())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -76,16 +78,14 @@ class MainActivity : ComponentActivity() {
|
|||
* stall tab switches. Hilt caches singletons thread-safely, so later
|
||||
* main-thread access is free.
|
||||
*/
|
||||
private fun warmRepositories() {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val entry = EntryPointAccessors.fromApplication(
|
||||
applicationContext, WarmEntryPoint::class.java,
|
||||
)
|
||||
entry.stationRepository().getAll()
|
||||
entry.streamRepository().getAll()
|
||||
entry.wavlakeRepository().isStationEnabled("")
|
||||
}
|
||||
private suspend fun warmRepositories() = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val entry = EntryPointAccessors.fromApplication(
|
||||
applicationContext, WarmEntryPoint::class.java,
|
||||
)
|
||||
entry.stationRepository().getAll()
|
||||
entry.streamRepository().getAll()
|
||||
entry.wavlakeRepository().isStationEnabled("")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
162
app/src/main/java/com/rada/ui/components/QrScannerDialog.kt
Normal file
162
app/src/main/java/com/rada/ui/components/QrScannerDialog.kt
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
package com.rada.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ExperimentalGetImage
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@androidx.annotation.OptIn(markerClass = [ExperimentalGetImage::class])
|
||||
@Composable
|
||||
fun QrScannerDialog(
|
||||
onResult: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasCameraPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> hasCameraPermission = granted }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!hasCameraPermission) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
|
||||
if (!hasCameraPermission) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Camera access needed") },
|
||||
text = { Text("Allow camera access to scan a Nostr Wallet Connect QR code.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }) {
|
||||
Text("Allow")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface {
|
||||
Column {
|
||||
androidx.compose.foundation.layout.Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.End,
|
||||
) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close scanner")
|
||||
}
|
||||
}
|
||||
val previewView = remember { PreviewView(context) }
|
||||
AndroidView(
|
||||
factory = { previewView },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.size(300.dp)
|
||||
.padding(16.dp),
|
||||
)
|
||||
Text(
|
||||
"Point the camera at the wallet's Nostr Wallet Connect QR code.",
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
)
|
||||
DisposableEffect(previewView, lifecycleOwner) {
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
val scanner = BarcodeScanning.getClient(
|
||||
BarcodeScannerOptions.Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||
.build(),
|
||||
)
|
||||
val delivered = AtomicBoolean(false)
|
||||
val providerFuture = ProcessCameraProvider.getInstance(context)
|
||||
providerFuture.addListener({
|
||||
val provider = providerFuture.get()
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.surfaceProvider = previewView.surfaceProvider
|
||||
}
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
analysis.setAnalyzer(executor) { imageProxy ->
|
||||
val image = imageProxy.image
|
||||
if (image == null || delivered.get()) {
|
||||
imageProxy.close()
|
||||
return@setAnalyzer
|
||||
}
|
||||
scanner.process(
|
||||
InputImage.fromMediaImage(
|
||||
image,
|
||||
imageProxy.imageInfo.rotationDegrees,
|
||||
),
|
||||
).addOnSuccessListener { codes ->
|
||||
val value = codes.firstNotNullOfOrNull { it.rawValue }
|
||||
if (value != null && delivered.compareAndSet(false, true)) {
|
||||
onResult(value)
|
||||
onDismiss()
|
||||
}
|
||||
}.addOnCompleteListener { imageProxy.close() }
|
||||
}
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
analysis,
|
||||
)
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
|
||||
onDispose {
|
||||
runCatching { providerFuture.get().unbindAll() }
|
||||
scanner.close()
|
||||
executor.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
|
|
@ -48,14 +49,17 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.camera.core.ExperimentalGetImage
|
||||
import com.rada.ui.theme.LocalTokens
|
||||
import com.rada.ui.theme.SatsAmpGlass
|
||||
import com.rada.ui.components.QrScannerDialog
|
||||
|
||||
/**
|
||||
* Decentralized settings sheet — progressive disclosure per the design:
|
||||
* listeners see "identity + wallet", advanced rows (relays, profile) sit
|
||||
* behind a toggle. All state via [DecentralizedViewModel].
|
||||
*/
|
||||
@androidx.annotation.OptIn(markerClass = [ExperimentalGetImage::class])
|
||||
@Composable
|
||||
fun DecentralizedSheet(
|
||||
onDismiss: () -> Unit,
|
||||
|
|
@ -92,6 +96,7 @@ fun DecentralizedSheet(
|
|||
var nwcInput by remember { mutableStateOf("") }
|
||||
var nameInput by remember { mutableStateOf("") }
|
||||
var aboutInput by remember { mutableStateOf("") }
|
||||
var showQrScanner by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
|
|
@ -239,14 +244,23 @@ fun DecentralizedSheet(
|
|||
style = MaterialTheme.typography.bodySmall,
|
||||
color = tokens.textSecondary,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = nwcInput,
|
||||
onValueChange = { nwcInput = it },
|
||||
placeholder = { Text("nostr+walletconnect://…") },
|
||||
singleLine = true,
|
||||
colors = fieldColors(tokens),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = nwcInput,
|
||||
onValueChange = { nwcInput = it },
|
||||
placeholder = { Text("nostr+walletconnect://…") },
|
||||
singleLine = true,
|
||||
colors = fieldColors(tokens),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = { showQrScanner = true }) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = "Scan wallet QR code",
|
||||
tint = tokens.accentText,
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { viewModel.connectWallet(nwcInput) },
|
||||
enabled = nwcInput.isNotBlank() && busy.not(),
|
||||
|
|
@ -356,6 +370,19 @@ fun DecentralizedSheet(
|
|||
TextButton(onClick = onDismiss) { Text("Close", color = tokens.accentText) }
|
||||
},
|
||||
)
|
||||
|
||||
if (showQrScanner) {
|
||||
QrScannerDialog(
|
||||
onResult = { scanned ->
|
||||
if (scanned.startsWith("nostr+walletconnect://")) {
|
||||
nwcInput = scanned
|
||||
} else {
|
||||
viewModel.showMessage("That QR code is not a Nostr Wallet Connect pairing code.")
|
||||
}
|
||||
},
|
||||
onDismiss = { showQrScanner = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -137,4 +137,6 @@ class DecentralizedViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun consumeMessage() { message.value = null }
|
||||
|
||||
fun showMessage(text: String) { message.value = text }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.rada.ui.discover
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import android.util.Log
|
||||
import com.rada.domain.model.WavlakeTrack
|
||||
import com.rada.payments.ZapService
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
|
@ -49,8 +50,9 @@ class WavlakeZapViewModel @Inject constructor(
|
|||
is ZapService.WavlakeZapOutcome.Failed ->
|
||||
TrackZapState.Failed(r.reason)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TrackZapState.Failed(e.message ?: "Invoice failed")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Wavlake invoice failed", e)
|
||||
TrackZapState.Failed(e.message ?: "Crypto or network setup failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -68,9 +70,14 @@ class WavlakeZapViewModel @Inject constructor(
|
|||
is ZapService.WavlakeZapOutcome.Failed ->
|
||||
TrackZapState.Failed(r.reason)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TrackZapState.Failed(e.message ?: "Zap failed")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Wavlake zap failed", e)
|
||||
TrackZapState.Failed(e.message ?: "Crypto, wallet, or network setup failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "WavlakeZap"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.Favorite
|
|||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.filled.PlaylistAdd
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
|
|
@ -76,6 +77,7 @@ import androidx.compose.ui.input.pointer.PointerEventPass
|
|||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
|
|
@ -195,6 +197,20 @@ fun LibraryScreen(
|
|||
Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play")
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = { viewModel.refreshImportedFolders() },
|
||||
enabled = !uiState.isImporting,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = "Refresh imported folders",
|
||||
tint = if (uiState.isImporting) {
|
||||
LocalTokens.current.textSecondary.copy(alpha = 0.4f)
|
||||
} else {
|
||||
LocalTokens.current.textSecondary
|
||||
},
|
||||
)
|
||||
}
|
||||
// Reliable delete path for the selected folder (besides long-press).
|
||||
if (uiState.selectedPlaylistId != null) {
|
||||
allPlaylists.firstOrNull { it.id == uiState.selectedPlaylistId }?.let { selected ->
|
||||
|
|
@ -451,6 +467,8 @@ fun LibraryScreen(
|
|||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = LocalTokens.current.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,23 @@ class LibraryViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun refreshImportedFolders() {
|
||||
if (_uiState.value.isImporting) return
|
||||
_uiState.update { it.copy(isImporting = true) }
|
||||
viewModelScope.launch {
|
||||
val result = songImporter.refreshImportedFolders()
|
||||
_uiState.update { it.copy(isImporting = false) }
|
||||
_importMessage.value = buildString {
|
||||
append("Refreshed ${result.imported} new song")
|
||||
if (result.imported != 1) append("s")
|
||||
if (result.skipped > 0) append(" · ${result.skipped} already in library")
|
||||
if (result.errors.isNotEmpty()) {
|
||||
append(" (${result.errors.first()})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearImportMessage() {
|
||||
_importMessage.value = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,16 +51,12 @@ fun TabsScreen(
|
|||
val scope = rememberCoroutineScope()
|
||||
// Deep-link target for Library (playlist picker → open that playlist).
|
||||
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
|
||||
// Tab taps jump: swiping is the animated path, but animating a tap from
|
||||
// Stations → Playlists scrolls through (and composes) every page in
|
||||
// between — that mid-flight compose/dispose churn is the jumpiness.
|
||||
// Adjacent taps still glide; distant ones land instantly like Spotify.
|
||||
// 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) {
|
||||
val from = pagerState.currentPage
|
||||
if (from == page) return
|
||||
if (pagerState.currentPage == page) return
|
||||
scope.launch {
|
||||
if (kotlin.math.abs(from - page) == 1) pagerState.animateScrollToPage(page)
|
||||
else pagerState.scrollToPage(page)
|
||||
pagerState.scrollToPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import androidx.compose.animation.AnimatedContent
|
|||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.togetherWith
|
||||
import com.rada.ui.components.EqualizerBars
|
||||
import com.rada.ui.theme.metadataEnter
|
||||
import com.rada.ui.theme.metadataExit
|
||||
import com.rada.ui.theme.LocalReduceMotion
|
||||
import com.rada.ui.theme.barEnter
|
||||
|
|
@ -186,20 +185,14 @@ fun NowPlayingBar(
|
|||
),
|
||||
) {
|
||||
Column {
|
||||
AnimatedContent(
|
||||
targetState = title,
|
||||
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
|
||||
label = "npTitle",
|
||||
) { t ->
|
||||
Text(
|
||||
text = t,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = LocalTokens.current.text,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = LocalTokens.current.text,
|
||||
)
|
||||
if (isError) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
@ -222,19 +215,13 @@ fun NowPlayingBar(
|
|||
)
|
||||
}
|
||||
} else if (subtitle.isNotEmpty()) {
|
||||
AnimatedContent(
|
||||
targetState = subtitle,
|
||||
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
|
||||
label = "npSubtitle",
|
||||
) { st ->
|
||||
Text(
|
||||
text = st,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = LocalTokens.current.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = LocalTokens.current.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import androidx.compose.animation.togetherWith
|
|||
import com.rada.ui.theme.LocalReduceMotion
|
||||
import com.rada.ui.theme.MotionArrival
|
||||
import com.rada.ui.theme.MotionExit
|
||||
import com.rada.ui.theme.metadataEnter
|
||||
import com.rada.ui.theme.metadataExit
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
|
|
@ -574,47 +573,36 @@ private fun NowPlayingView(
|
|||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Artist/Station name
|
||||
AnimatedContent(
|
||||
targetState = uiState.artist,
|
||||
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
|
||||
label = "artist",
|
||||
) { artist ->
|
||||
// Replace metadata immediately. Some stations emit updates in quick
|
||||
// succession; animated transitions can queue and show stale titles.
|
||||
Text(
|
||||
text = uiState.artist.ifEmpty { "No station" },
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
// Song title
|
||||
if (uiState.title.isNotEmpty()) {
|
||||
Text(
|
||||
text = artist.ifEmpty { "No station" },
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
text = uiState.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
} else if (uiState.isPlaying && uiState.currentStation != null) {
|
||||
Text(
|
||||
text = if (uiState.hasMetadata) "" else "No track info from this station",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Song title
|
||||
AnimatedContent(
|
||||
targetState = uiState.title,
|
||||
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
|
||||
label = "title",
|
||||
) { title ->
|
||||
if (title.isNotEmpty()) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
} else if (uiState.isPlaying && uiState.currentStation != null) {
|
||||
Text(
|
||||
text = if (uiState.hasMetadata) "" else "No track info from this station",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue