Compare commits
10 commits
ee0d6b6149
...
1c9d07251a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c9d07251a | ||
|
|
5ff5318db4 | ||
|
|
e2ed74a47d | ||
|
|
918aeaf8d3 | ||
|
|
172e932ef3 | ||
|
|
d612ba6f49 | ||
|
|
eb32aed07c | ||
|
|
4f8d3e9299 | ||
|
|
5b524f2153 | ||
|
|
ec55ba84ae |
61 changed files with 7955 additions and 668 deletions
|
|
@ -26,7 +26,7 @@ Most radio apps play streams but discard what's on them. SatsAmp treats the live
|
||||||
## Operating Context
|
## Operating Context
|
||||||
|
|
||||||
- Single-user personal device use; headphone and speaker listening.
|
- Single-user personal device use; headphone and speaker listening.
|
||||||
- Streams are live and can drop; playback is a foreground service with notification controls.
|
- Streams are live and can drop; playback is a foreground service with notification controls. Swiping the task away stops playback and clears the notification (closed means stopped); screen-off listening with the task alive keeps playing.
|
||||||
- Library is local (Room DB, `radamp.db`); saved songs, playlists, and imported files live on-device.
|
- Library is local (Room DB, `radamp.db`); saved songs, playlists, and imported files live on-device.
|
||||||
- Stations are built-in defaults plus user-added streams (direct URL or M3U playlist import).
|
- Stations are built-in defaults plus user-added streams (direct URL or M3U playlist import).
|
||||||
|
|
||||||
|
|
@ -45,7 +45,7 @@ Most radio apps play streams but discard what's on them. SatsAmp treats the live
|
||||||
|
|
||||||
## Evidence on Hand
|
## Evidence on Hand
|
||||||
|
|
||||||
- 43 built-in stations, all verified live 2026-09-07 (HTTP 200 + audio payload; probe results were ephemeral in /tmp, not committed).
|
- 11 built-in stations (cliamp.stream music channels in playlist order: Lofi → … → NCS Chill), all verified live 2026-09-09 (HTTP 200 + audio payload; probe results were ephemeral, not committed). Media Monarchy ships as a built-in stream in the Streams tab instead (talk, not music).
|
||||||
- Working recorder pipeline (`recording/`: IcyStreamReader, SongBoundaryDetector, RollingAudioBuffer, MetadataEmbedder).
|
- Working recorder pipeline (`recording/`: IcyStreamReader, SongBoundaryDetector, RollingAudioBuffer, MetadataEmbedder).
|
||||||
- Design scratch work in `design/` (glass theme CSS + demo).
|
- Design scratch work in `design/` (glass theme CSS + demo).
|
||||||
- Absences that must not be fabricated: no testimonials, no user base, no press, no pricing, no privacy policy text.
|
- Absences that must not be fabricated: no testimonials, no user base, no press, no pricing, no privacy policy text.
|
||||||
|
|
|
||||||
|
|
@ -104,9 +104,15 @@ dependencies {
|
||||||
// not exist on Maven Central), which made the build fail during
|
// not exist on Maven Central), which made the build fail during
|
||||||
// dependency resolution before Kotlin was ever compiled.
|
// dependency resolution before Kotlin was ever compiled.
|
||||||
implementation("net.jthink:jaudiotagger:2.2.5")
|
implementation("net.jthink:jaudiotagger:2.2.5")
|
||||||
|
// Nostr/Lightning crypto: BIP-340 schnorr + ECDH via ACINQ's secp256k1
|
||||||
|
// Kotlin-multiplatform binding (prebuilt .so for all Android ABIs).
|
||||||
|
implementation("fr.acinq.secp256k1:secp256k1-kmp:0.17.3")
|
||||||
|
implementation("fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.17.3")
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
testImplementation("fr.acinq.secp256k1:secp256k1-kmp-jni-jvm-extract:0.17.3")
|
||||||
|
testImplementation("fr.acinq.secp256k1:secp256k1-kmp-jni-jvm-linux:0.17.3")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||||
androidTestImplementation(composeBom)
|
androidTestImplementation(composeBom)
|
||||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
|
|
|
||||||
11
app/proguard-rules.pro
vendored
11
app/proguard-rules.pro
vendored
|
|
@ -1,2 +1,13 @@
|
||||||
-dontwarn javax.annotation.**
|
-dontwarn javax.annotation.**
|
||||||
-keep class com.rada.data.local.entity.** { *; }
|
-keep class com.rada.data.local.entity.** { *; }
|
||||||
|
|
||||||
|
# jaudiotagger maps tag frames via reflection; without keeps the release
|
||||||
|
# minifier breaks its writer (silent NPE, files saved untagged).
|
||||||
|
-keep class org.jaudiotagger.** { *; }
|
||||||
|
-keepattributes Signature, InnerClasses, EnclosingMethod
|
||||||
|
# jaudiotagger also references desktop-only image/test APIs that Android
|
||||||
|
# never executes (artwork scaling, test helpers).
|
||||||
|
-dontwarn org.jaudiotagger.**
|
||||||
|
-dontwarn java.awt.**
|
||||||
|
-dontwarn javax.imageio.**
|
||||||
|
-dontwarn javax.swing.**
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,16 @@
|
||||||
<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" />
|
||||||
|
|
||||||
|
<!-- NIP-55 external signer discovery (Amber, etc.) -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="nostrsigner" />
|
||||||
|
</intent>
|
||||||
|
<package android:name="com.greenart7c3.nostrsigner" />
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".SatsAmpApp"
|
android:name=".SatsAmpApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|
|
||||||
|
|
@ -2,376 +2,106 @@ package com.rada.data.local
|
||||||
|
|
||||||
import com.rada.domain.model.Station
|
import com.rada.domain.model.Station
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in lineup: the 11 cliamp.stream music channels in playlist order
|
||||||
|
* (Lofi → … → NCS Chill). Order is curated display order —
|
||||||
|
* [StationRepository] / UI preserve it instead of sorting alphabetically.
|
||||||
|
*
|
||||||
|
* Media Monarchy lives in the Streams tab (it is talk, not music) and is
|
||||||
|
* seeded there by [com.rada.data.repository.StreamRepository].
|
||||||
|
*
|
||||||
|
* IDs reuse the pre-existing cliamp entries so favorites and renames carry
|
||||||
|
* over. All URLs verified live (HTTP 200 + audio payload).
|
||||||
|
*/
|
||||||
object DefaultStations {
|
object DefaultStations {
|
||||||
|
|
||||||
private val builtIn = listOf(
|
private val builtIn = listOf(
|
||||||
// ── Lofi ──
|
|
||||||
Station(
|
|
||||||
id = "fluxfm-chillhop",
|
|
||||||
name = "FLUX FM ChillHop",
|
|
||||||
url = "http://streams.fluxfm.de/Chillhop/mp3-320",
|
|
||||||
genre = "Lofi / Chillhop",
|
|
||||||
description = "Chill beats and lofi hip-hop from FLUX FM Berlin",
|
|
||||||
country = "DE",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "lofi-24-7",
|
|
||||||
name = "bigFM LoFi Focus",
|
|
||||||
url = "https://stream.bigfm.de/lofifocus/mp3-128/radiobrowser",
|
|
||||||
genre = "Lofi",
|
|
||||||
description = "Lo-fi beats to focus and study — from bigFM",
|
|
||||||
country = "DE",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "chillofi-radio",
|
|
||||||
name = "Chillofi Radio",
|
|
||||||
url = "https://azc.rdstream-5677.dez.ovh/listen/chillofi/radio.mp3",
|
|
||||||
genre = "Lofi / Chillhop",
|
|
||||||
description = "Relax and study beats from France",
|
|
||||||
country = "FR",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "reyfm-lofi",
|
|
||||||
name = "REYFM #LOFI",
|
|
||||||
url = "https://listen.reyfm.de/lofi_320kbps.mp3",
|
|
||||||
genre = "Lofi",
|
|
||||||
description = "German lofi radio — chill vibes at 320kbps",
|
|
||||||
country = "DE",
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Synthwave ──
|
|
||||||
Station(
|
|
||||||
id = "synthwave-city-fm",
|
|
||||||
name = "Synthwave City FM",
|
|
||||||
url = "https://synthwave-rex.radioca.st/stream",
|
|
||||||
genre = "Synthwave",
|
|
||||||
description = "Retro synth, outrun, and neon-soaked beats",
|
|
||||||
country = "NL",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "synthwave-radio",
|
|
||||||
name = "Synthwave Radio",
|
|
||||||
url = "https://stream.synthwaveradio.eu/listen/synthwaveradio.eu/radio.mp3",
|
|
||||||
genre = "Synthwave",
|
|
||||||
description = "Synthwave, retrowave, and outrun",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "synthwave-neon-city",
|
|
||||||
name = "Synthwave Neon City",
|
|
||||||
url = "https://stream.laut.fm/synthwave",
|
|
||||||
genre = "Synthwave",
|
|
||||||
description = "Neon-drenched synthwave from laut.fm",
|
|
||||||
country = "DE",
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── EDM ──
|
|
||||||
Station(
|
|
||||||
id = "electrodanceworld",
|
|
||||||
name = "PulseEDM",
|
|
||||||
url = "http://pulseedm.cdnstream1.com:8124/1373_128",
|
|
||||||
genre = "EDM / Dance",
|
|
||||||
description = "EDM, house, trance, dubstep — PulseEDM Radio",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "technolovers-edm",
|
|
||||||
name = "Technolovers EDM",
|
|
||||||
url = "https://stream.technolovers.fm/edm",
|
|
||||||
genre = "EDM",
|
|
||||||
description = "Club house, electro, and electronic dance music",
|
|
||||||
country = "DE",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "generation-dance",
|
|
||||||
name = "Generation Dance Radio",
|
|
||||||
url = "http://generationdance.lu/radio/8030/hd",
|
|
||||||
genre = "EDM / Dance",
|
|
||||||
description = "Eurodance, hands up, house, and trance",
|
|
||||||
country = "LU",
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Phonk ──
|
|
||||||
Station(
|
|
||||||
id = "phonkio-radio",
|
|
||||||
name = "Phonk Radio",
|
|
||||||
url = "https://stream.phonkioradio.com/listen/phonkio_radio/radio.mp3",
|
|
||||||
genre = "Phonk",
|
|
||||||
description = "24/7 phonk — drift, Memphis, and cowbells",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "badradio-phonk",
|
|
||||||
name = "badradio 24/7",
|
|
||||||
url = "https://s2.radio.co/s2b2b68744/listen",
|
|
||||||
genre = "Phonk / Memphis",
|
|
||||||
description = "OG phonk, Memphis, Houston, trap & trill",
|
|
||||||
country = "NZ",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "dopephonk",
|
|
||||||
name = "DOPEPHONK",
|
|
||||||
url = "https://listen.atomic.radio/dopephonk/middlequality",
|
|
||||||
genre = "Phonk",
|
|
||||||
description = "Pure phonk vibes 24/7",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Classics / Classical ──
|
|
||||||
Station(
|
|
||||||
id = "classic-fm-uk",
|
|
||||||
name = "CFMZ Classical FM",
|
|
||||||
url = "https://live.amperwave.net/direct/mzmedia-cfmzfmmp3-ibc2",
|
|
||||||
genre = "Classical",
|
|
||||||
description = "Classical FM from Canada — baroque to romantic",
|
|
||||||
country = "CA",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "your-classical-relax",
|
|
||||||
name = "Your Classical - Relax",
|
|
||||||
url = "http://relax.stream.publicradio.org/relax.mp3",
|
|
||||||
genre = "Classical / Relax",
|
|
||||||
description = "Peaceful classical music from American Public Media",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "classical-piano",
|
|
||||||
name = "Peaceful Piano",
|
|
||||||
url = "https://holiday.stream.publicradio.org/peacefulpiano.mp3",
|
|
||||||
genre = "Classical Piano",
|
|
||||||
description = "Peaceful classical piano — Chopin, Debussy, Einaudi",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "classical-guitar-181",
|
|
||||||
name = "181.FM - Classical Guitar",
|
|
||||||
url = "https://listen.181fm.com/181-classicalguitar_128k.mp3",
|
|
||||||
genre = "Classical Guitar",
|
|
||||||
description = "Classical and Spanish guitar from 181.FM",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "epic-classical-piano",
|
|
||||||
name = "EPIC CLASSICAL - Piano",
|
|
||||||
url = "https://stream.epic-classical.com/classical-piano",
|
|
||||||
genre = "Classical Piano",
|
|
||||||
description = "Ambient, chill, and relaxing classical piano",
|
|
||||||
country = "DE",
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Strings / Ambient ──
|
|
||||||
Station(
|
|
||||||
id = "soma-fm-lush",
|
|
||||||
name = "Lush",
|
|
||||||
url = "https://ice1.somafm.com/lush-128-mp3",
|
|
||||||
genre = "Strings / Ambient",
|
|
||||||
description = "Sensuous and mellow vocals with lush string textures",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "soma-fm-dronezone",
|
|
||||||
name = "Drone Zone",
|
|
||||||
url = "https://ice1.somafm.com/dronezone-128-mp3",
|
|
||||||
genre = "Ambient / Strings",
|
|
||||||
description = "Atmospheric textures with beats and samples",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Electronic / NCS ──
|
|
||||||
Station(
|
|
||||||
id = "ncs-radio",
|
|
||||||
name = "NCS Radio",
|
|
||||||
url = "https://radio.cliamp.stream/ncs/stream",
|
|
||||||
genre = "Electronic / NCS",
|
|
||||||
description = "NoCopyrightSounds — unlimited gaming music",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── cliamp.stream ──
|
|
||||||
Station(
|
Station(
|
||||||
id = "cliamp-lofi",
|
id = "cliamp-lofi",
|
||||||
name = "Lofi Hip Hop Radio",
|
name = "Lofi Stream",
|
||||||
url = "https://radio.cliamp.stream/lofi/stream",
|
url = "https://radio.cliamp.stream/lofi/stream",
|
||||||
genre = "Lofi / Hip-Hop",
|
genre = "Lofi",
|
||||||
description = "24/7 lofi hip-hop beats to chill and code to — from cliamp.stream",
|
description = "24/7 lofi beats to chill and code to",
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "cliamp-edm",
|
|
||||||
name = "EDM Pulse",
|
|
||||||
url = "https://radio.cliamp.stream/edm/stream",
|
|
||||||
genre = "EDM / Dance",
|
|
||||||
description = "Non-stop EDM pulse — from cliamp.stream",
|
|
||||||
country = "US",
|
country = "US",
|
||||||
),
|
),
|
||||||
Station(
|
Station(
|
||||||
id = "cliamp-synthwave",
|
id = "cliamp-synthwave",
|
||||||
name = "Synthwave Nights",
|
name = "Synthwave Stream",
|
||||||
url = "https://radio.cliamp.stream/synthwave/stream",
|
url = "https://radio.cliamp.stream/synthwave/stream",
|
||||||
genre = "Synthwave",
|
genre = "Synthwave",
|
||||||
description = "Retro synthwave nights — neon drives at midnight, from cliamp.stream",
|
description = "Retro synthwave nights — neon drives at midnight",
|
||||||
country = "US",
|
country = "US",
|
||||||
),
|
),
|
||||||
Station(
|
Station(
|
||||||
id = "cliamp-omarchy",
|
id = "cliamp-edm",
|
||||||
name = "Omarchy Radio",
|
name = "EDM Stream",
|
||||||
url = "https://radio.cliamp.stream/omarchy/stream",
|
url = "https://radio.cliamp.stream/edm/stream",
|
||||||
genre = "Electronic / Community",
|
genre = "EDM",
|
||||||
description = "The Omarchy Linux community radio station — from cliamp.stream",
|
description = "Non-stop EDM pulse",
|
||||||
|
country = "US",
|
||||||
|
),
|
||||||
|
Station(
|
||||||
|
id = "ncs-radio",
|
||||||
|
name = "NCS Stream",
|
||||||
|
url = "https://radio.cliamp.stream/ncs/stream",
|
||||||
|
genre = "NCS",
|
||||||
|
description = "NoCopyrightSounds — unlimited gaming music",
|
||||||
country = "US",
|
country = "US",
|
||||||
),
|
),
|
||||||
Station(
|
Station(
|
||||||
id = "ncs-house",
|
id = "ncs-house",
|
||||||
name = "NCS House",
|
name = "NCS House Stream",
|
||||||
url = "https://radio.cliamp.stream/ncs-house/stream",
|
url = "https://radio.cliamp.stream/ncs-house/stream",
|
||||||
genre = "Electronic / House",
|
genre = "NCS",
|
||||||
description = "NoCopyrightSounds house mixes — from cliamp.stream",
|
description = "NoCopyrightSounds house mixes",
|
||||||
country = "US",
|
country = "US",
|
||||||
),
|
),
|
||||||
Station(
|
Station(
|
||||||
id = "ncs-dubstep",
|
id = "ncs-dubstep",
|
||||||
name = "NCS Dubstep",
|
name = "NCS Dubstep Stream",
|
||||||
url = "https://radio.cliamp.stream/ncs-dubstep/stream",
|
url = "https://radio.cliamp.stream/ncs-dubstep/stream",
|
||||||
genre = "Electronic / Dubstep",
|
genre = "NCS",
|
||||||
description = "NoCopyrightSounds dubstep — heavy bass drops, from cliamp.stream",
|
description = "NoCopyrightSounds dubstep — heavy bass drops",
|
||||||
country = "US",
|
country = "US",
|
||||||
),
|
),
|
||||||
Station(
|
Station(
|
||||||
id = "ncs-dnb",
|
id = "ncs-dnb",
|
||||||
name = "NCS Drum & Bass",
|
name = "NCS Drum & Bass Stream",
|
||||||
url = "https://radio.cliamp.stream/ncs-dnb/stream",
|
url = "https://radio.cliamp.stream/ncs-dnb/stream",
|
||||||
genre = "Electronic / Drum & Bass",
|
genre = "NCS",
|
||||||
description = "NoCopyrightSounds drum & bass — from cliamp.stream",
|
description = "NoCopyrightSounds drum & bass",
|
||||||
country = "US",
|
country = "US",
|
||||||
),
|
),
|
||||||
Station(
|
Station(
|
||||||
id = "ncs-trap",
|
id = "ncs-trap",
|
||||||
name = "NCS Trap",
|
name = "NCS Trap Stream",
|
||||||
url = "https://radio.cliamp.stream/ncs-trap/stream",
|
url = "https://radio.cliamp.stream/ncs-trap/stream",
|
||||||
genre = "Electronic / Trap",
|
genre = "NCS",
|
||||||
description = "NoCopyrightSounds trap beats — from cliamp.stream",
|
description = "NoCopyrightSounds trap beats",
|
||||||
country = "US",
|
country = "US",
|
||||||
),
|
),
|
||||||
Station(
|
Station(
|
||||||
id = "ncs-phonk",
|
id = "ncs-phonk",
|
||||||
name = "NCS Phonk",
|
name = "NCS Phonk Stream",
|
||||||
url = "https://radio.cliamp.stream/ncs-phonk/stream",
|
url = "https://radio.cliamp.stream/ncs-phonk/stream",
|
||||||
genre = "Phonk",
|
genre = "NCS",
|
||||||
description = "NoCopyrightSounds phonk — drift phonk and cowbells, from cliamp.stream",
|
description = "NoCopyrightSounds phonk — drift phonk and cowbells",
|
||||||
country = "US",
|
country = "US",
|
||||||
),
|
),
|
||||||
Station(
|
Station(
|
||||||
id = "ncs-pop",
|
id = "ncs-pop",
|
||||||
name = "NCS Pop",
|
name = "NCS Pop Stream",
|
||||||
url = "https://radio.cliamp.stream/ncs-pop/stream",
|
url = "https://radio.cliamp.stream/ncs-pop/stream",
|
||||||
genre = "Electronic / Pop",
|
genre = "NCS",
|
||||||
description = "NoCopyrightSounds pop hits — from cliamp.stream",
|
description = "NoCopyrightSounds pop hits",
|
||||||
country = "US",
|
country = "US",
|
||||||
),
|
),
|
||||||
Station(
|
Station(
|
||||||
id = "ncs-chill",
|
id = "ncs-chill",
|
||||||
name = "NCS Chill",
|
name = "NCS Chill Stream",
|
||||||
url = "https://radio.cliamp.stream/ncs-chill/stream",
|
url = "https://radio.cliamp.stream/ncs-chill/stream",
|
||||||
genre = "Electronic / Chill",
|
genre = "NCS",
|
||||||
description = "NoCopyrightSounds chill mixes — from cliamp.stream",
|
description = "NoCopyrightSounds chill mixes",
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── SomaFM classics ──
|
|
||||||
Station(
|
|
||||||
id = "soma-fm-groovesalad",
|
|
||||||
name = "Groove Salad",
|
|
||||||
url = "https://ice1.somafm.com/groovesalad-128-mp3",
|
|
||||||
genre = "Ambient / Downtempo",
|
|
||||||
description = "A nicely chilled plate of ambient beats and grooves",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "soma-fm-spacestation",
|
|
||||||
name = "Space Station Soma",
|
|
||||||
url = "https://ice1.somafm.com/spacestation-128-mp3",
|
|
||||||
genre = "Ambient / Electronica",
|
|
||||||
description = "Tune in, turn on, drift away",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "soma-fm-defcon",
|
|
||||||
name = "DEF CON",
|
|
||||||
url = "https://ice1.somafm.com/defcon-128-mp3",
|
|
||||||
genre = "Electronic / Hacker",
|
|
||||||
description = "The DEF CON hacker convention radio",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "soma-fm-thetrip",
|
|
||||||
name = "The Trip",
|
|
||||||
url = "https://ice1.somafm.com/thetrip-128-mp3",
|
|
||||||
genre = "House / Techno",
|
|
||||||
description = "Progressive house / trance. Tip top tunes.",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "soma-fm-fluid",
|
|
||||||
name = "Fluid",
|
|
||||||
url = "https://ice1.somafm.com/fluid-128-mp3",
|
|
||||||
genre = "Instrumental Hip-Hop",
|
|
||||||
description = "Instrumental hip-hop beats and grooves",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "soma-fm-seventies",
|
|
||||||
name = "Left Coast 70s",
|
|
||||||
url = "https://ice1.somafm.com/seventies-128-mp3",
|
|
||||||
genre = "Retro / 70s",
|
|
||||||
description = "Mellow grooves from the 1970s",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Classic Rock ──
|
|
||||||
Station(
|
|
||||||
id = "rdmix-classic-rock",
|
|
||||||
name = "RdMix Classic Rock",
|
|
||||||
url = "https://cast1.torontocast.com:4610/stream",
|
|
||||||
genre = "Classic Rock",
|
|
||||||
description = "Classic rock from the 70s, 80s, and 90s",
|
|
||||||
country = "CA",
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Oldies ──
|
|
||||||
Station(
|
|
||||||
id = "good-time-oldies",
|
|
||||||
name = "Good Time Oldies",
|
|
||||||
url = "http://listen.181fm.com/181-goodtime_128k.mp3",
|
|
||||||
genre = "Oldies",
|
|
||||||
description = "The greatest oldies from the 50s and 60s",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Jazz ──
|
|
||||||
Station(
|
|
||||||
id = "smooth-jazz-101",
|
|
||||||
name = "101 Smooth Jazz",
|
|
||||||
url = "http://jking.cdnstream1.com/b22139_128mp3",
|
|
||||||
genre = "Jazz / Smooth Jazz",
|
|
||||||
description = "Smooth jazz — saxophone, keys, and chill grooves",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Eclectic / Funk / Hip-Hop ──
|
|
||||||
Station(
|
|
||||||
id = "kcrw-eclectic",
|
|
||||||
name = "KCRW Eclectic 24",
|
|
||||||
url = "https://streams.kcrw.com/e24_mp3",
|
|
||||||
genre = "Eclectic / Funk / Hip-Hop / Jazz",
|
|
||||||
description = "Eclectic mix — funk, hip-hop, jazz, alternative, experimental",
|
|
||||||
country = "US",
|
|
||||||
),
|
|
||||||
Station(
|
|
||||||
id = "radio-paradise-eclectic",
|
|
||||||
name = "Radio Paradise Eclectic",
|
|
||||||
url = "http://stream.radioparadise.com/eclectic-320",
|
|
||||||
genre = "Eclectic / World / Rock / Funk",
|
|
||||||
description = "Eclectic world mix — rock, funk, world, and everything in between",
|
|
||||||
country = "US",
|
country = "US",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import com.rada.data.local.entity.SavedSongEntity
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [SavedSongEntity::class, PlaylistEntity::class, PlaylistSongEntity::class],
|
entities = [SavedSongEntity::class, PlaylistEntity::class, PlaylistSongEntity::class],
|
||||||
version = 2,
|
version = 4,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class SatsAmpDatabase : RoomDatabase() {
|
abstract class SatsAmpDatabase : RoomDatabase() {
|
||||||
|
|
@ -50,12 +50,27 @@ abstract class SatsAmpDatabase : RoomDatabase() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE saved_songs ADD COLUMN source TEXT NOT NULL DEFAULT 'local'")
|
||||||
|
db.execSQL("ALTER TABLE saved_songs ADD COLUMN sourceUrl TEXT NOT NULL DEFAULT ''")
|
||||||
|
db.execSQL("ALTER TABLE saved_songs ADD COLUMN artworkUrl TEXT NOT NULL DEFAULT ''")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE saved_songs ADD COLUMN wavlakeArtistId TEXT NOT NULL DEFAULT ''")
|
||||||
|
db.execSQL("ALTER TABLE saved_songs ADD COLUMN wavlakeArtistNpub TEXT NOT NULL DEFAULT ''")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun create(context: Context): SatsAmpDatabase {
|
fun create(context: Context): SatsAmpDatabase {
|
||||||
return Room.databaseBuilder(
|
return Room.databaseBuilder(
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
SatsAmpDatabase::class.java,
|
SatsAmpDatabase::class.java,
|
||||||
"radamp.db",
|
"radamp.db",
|
||||||
).addMigrations(MIGRATION_1_2).build()
|
).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4).build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,19 @@ interface PlaylistDao {
|
||||||
)
|
)
|
||||||
fun getSongsInPlaylist(playlistId: Long): Flow<List<SavedSongEntity>>
|
fun getSongsInPlaylist(playlistId: Long): Flow<List<SavedSongEntity>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of songs across several playlists. DISTINCT because a song can
|
||||||
|
* sit in more than one selected list.
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT s.* FROM saved_songs s
|
||||||
|
INNER JOIN playlist_songs ps ON s.id = ps.songId
|
||||||
|
WHERE ps.playlistId IN (:playlistIds)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun getSongsInPlaylists(playlistIds: List<Long>): Flow<List<SavedSongEntity>>
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM playlist_songs WHERE playlistId = :playlistId")
|
@Query("SELECT COUNT(*) FROM playlist_songs WHERE playlistId = :playlistId")
|
||||||
fun getPlaylistSongCount(playlistId: Long): Flow<Int>
|
fun getPlaylistSongCount(playlistId: Long): Flow<Int>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ interface SavedSongDao {
|
||||||
@Query("SELECT * FROM saved_songs ORDER BY dateSaved DESC")
|
@Query("SELECT * FROM saved_songs ORDER BY dateSaved DESC")
|
||||||
fun getAllByDate(): Flow<List<SavedSongEntity>>
|
fun getAllByDate(): Flow<List<SavedSongEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM saved_songs ORDER BY id ASC")
|
||||||
|
suspend fun getAllOnce(): List<SavedSongEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM saved_songs ORDER BY artist ASC, title ASC")
|
@Query("SELECT * FROM saved_songs ORDER BY artist ASC, title ASC")
|
||||||
fun getAllByArtist(): Flow<List<SavedSongEntity>>
|
fun getAllByArtist(): Flow<List<SavedSongEntity>>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,14 @@ data class SavedSongEntity(
|
||||||
val format: String = "mp3",
|
val format: String = "mp3",
|
||||||
val dateSaved: Long = System.currentTimeMillis(),
|
val dateSaved: Long = System.currentTimeMillis(),
|
||||||
val isFavorite: Boolean = false,
|
val isFavorite: Boolean = false,
|
||||||
|
/** Track origin: "local" (default) or "wavlake" (streamable catalog import). */
|
||||||
|
val source: String = "local",
|
||||||
|
/** Canonical source URL (Wavlake track page). Empty for local files. */
|
||||||
|
val sourceUrl: String = "",
|
||||||
|
/** Artwork URL (Wavlake imports). Empty for local files. */
|
||||||
|
val artworkUrl: String = "",
|
||||||
|
/** Wavlake artist id (imports only) — links plays back to zap addresses. */
|
||||||
|
val wavlakeArtistId: String = "",
|
||||||
|
/** Wavlake artist npub (imports only) — enables Nostr address resolution. */
|
||||||
|
val wavlakeArtistNpub: String = "",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
68
app/src/main/java/com/rada/data/repository/DedupeLibrary.kt
Normal file
68
app/src/main/java/com/rada/data/repository/DedupeLibrary.kt
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
package com.rada.data.repository
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.rada.data.local.dao.PlaylistDao
|
||||||
|
import com.rada.data.local.dao.SavedSongDao
|
||||||
|
import com.rada.data.local.entity.SavedSongEntity
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapses duplicate library songs (same artist + title, case-insensitive).
|
||||||
|
* Duplicates arise mainly from save-from-air: saving the same broadcast twice
|
||||||
|
* wrote a second file ("Song (1).mp3") and a second row.
|
||||||
|
*
|
||||||
|
* Keep rule: the largest file (the most complete recording; equal sizes tie
|
||||||
|
* to the oldest entry). Dropped songs' playlist links are re-pointed to the
|
||||||
|
* kept song before their rows and files are deleted.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class DedupeLibrary @Inject constructor(
|
||||||
|
private val savedSongDao: SavedSongDao,
|
||||||
|
private val playlistDao: PlaylistDao,
|
||||||
|
) {
|
||||||
|
data class Result(val removed: Int)
|
||||||
|
|
||||||
|
suspend fun run(): Result {
|
||||||
|
val all = savedSongDao.getAllOnce()
|
||||||
|
val groups = all.groupBy {
|
||||||
|
"${it.artist.trim().lowercase()}${it.title.trim().lowercase()}"
|
||||||
|
}
|
||||||
|
var removed = 0
|
||||||
|
for ((_, group) in groups) {
|
||||||
|
if (group.size < 2) continue
|
||||||
|
// Largest file wins; ties → oldest (smallest id).
|
||||||
|
val keeper = group.maxWithOrNull(
|
||||||
|
compareBy<SavedSongEntity> { it.fileSize }.thenBy { -it.id }
|
||||||
|
) ?: continue
|
||||||
|
for (dup in group.filter { it.id != keeper.id }) {
|
||||||
|
// Preserve group membership: re-point links, skip if the
|
||||||
|
// keeper is already in that playlist.
|
||||||
|
for (playlistId in playlistDao.getPlaylistIdsForSong(dup.id)) {
|
||||||
|
if (!playlistDao.isSongInPlaylist(playlistId, keeper.id)) {
|
||||||
|
playlistDao.addSongToPlaylist(
|
||||||
|
com.rada.data.local.entity.PlaylistSongEntity(
|
||||||
|
playlistId = playlistId,
|
||||||
|
songId = keeper.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
savedSongDao.deleteById(dup.id)
|
||||||
|
removed++
|
||||||
|
try {
|
||||||
|
if (dup.filePath != keeper.filePath) File(dup.filePath).delete()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Deduped ${group.size - 1} copy(ies) of " +
|
||||||
|
"${keeper.artist} — ${keeper.title}")
|
||||||
|
}
|
||||||
|
return Result(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "DedupeLibrary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,11 @@ class PlaylistRepository @Inject constructor(
|
||||||
return playlistDao.getSongsInPlaylist(playlistId)
|
return playlistDao.getSongsInPlaylist(playlistId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Union of songs across several playlists (for multi-list shuffle). */
|
||||||
|
fun getSongsInPlaylists(playlistIds: List<Long>): Flow<List<SavedSongEntity>> {
|
||||||
|
return playlistDao.getSongsInPlaylists(playlistIds)
|
||||||
|
}
|
||||||
|
|
||||||
fun getPlaylistSongCount(playlistId: Long): Flow<Int> {
|
fun getPlaylistSongCount(playlistId: Long): Flow<Int> {
|
||||||
return playlistDao.getPlaylistSongCount(playlistId)
|
return playlistDao.getPlaylistSongCount(playlistId)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ class StationRepository @Inject constructor(
|
||||||
private val stations = mutableListOf<Station>()
|
private val stations = mutableListOf<Station>()
|
||||||
private val nameOverrides = mutableMapOf<String, String>()
|
private val nameOverrides = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
/** Built-in station ids the user deleted. Persisted; survives restarts. */
|
||||||
|
private val _hiddenIds = MutableStateFlow(loadHiddenIds())
|
||||||
|
val hiddenIds: StateFlow<Set<String>> = _hiddenIds.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
nameOverrides.putAll(loadNameOverrides())
|
nameOverrides.putAll(loadNameOverrides())
|
||||||
rebuild()
|
rebuild()
|
||||||
|
|
@ -38,10 +42,42 @@ class StationRepository @Inject constructor(
|
||||||
saveCustomStations()
|
saveCustomStations()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes any station. Custom stations are removed; built-ins are
|
||||||
|
* hidden (persisted) so they stay gone across restarts. See
|
||||||
|
* [unhideStation]/[restoreHiddenStations] for the way back.
|
||||||
|
*/
|
||||||
fun removeStation(id: String) {
|
fun removeStation(id: String) {
|
||||||
|
if (DefaultStations.getById(id) != null) {
|
||||||
|
if (id !in _hiddenIds.value) {
|
||||||
|
_hiddenIds.value = _hiddenIds.value + id
|
||||||
|
saveHiddenIds(_hiddenIds.value)
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
stations.removeAll { it.id == id }
|
stations.removeAll { it.id == id }
|
||||||
if (nameOverrides.remove(id) != null) saveNameOverrides()
|
if (nameOverrides.remove(id) != null) saveNameOverrides()
|
||||||
saveCustomStations()
|
saveCustomStations()
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un-hides one previously hidden built-in station. No-op otherwise. */
|
||||||
|
fun unhideStation(id: String) {
|
||||||
|
if (id in _hiddenIds.value) {
|
||||||
|
_hiddenIds.value = _hiddenIds.value - id
|
||||||
|
saveHiddenIds(_hiddenIds.value)
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restores all hidden built-in stations. */
|
||||||
|
fun restoreHiddenStations() {
|
||||||
|
if (_hiddenIds.value.isNotEmpty()) {
|
||||||
|
_hiddenIds.value = emptySet()
|
||||||
|
saveHiddenIds(emptySet())
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isCustomStation(id: String): Boolean {
|
fun isCustomStation(id: String): Boolean {
|
||||||
|
|
@ -125,7 +161,7 @@ class StationRepository @Inject constructor(
|
||||||
|
|
||||||
private fun rebuild() {
|
private fun rebuild() {
|
||||||
stations.clear()
|
stations.clear()
|
||||||
stations.addAll(DefaultStations.getAll())
|
stations.addAll(DefaultStations.getAll().filter { it.id !in _hiddenIds.value })
|
||||||
stations.addAll(loadCustomStations())
|
stations.addAll(loadCustomStations())
|
||||||
for (i in stations.indices) {
|
for (i in stations.indices) {
|
||||||
val station = stations[i]
|
val station = stations[i]
|
||||||
|
|
@ -135,8 +171,7 @@ class StationRepository @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadNameOverrides(): Map<String, String> {
|
private fun loadNameOverrides(): Map<String, String> { val json = prefs.getString(KEY_NAME_OVERRIDES, null) ?: return emptyMap()
|
||||||
val json = prefs.getString(KEY_NAME_OVERRIDES, null) ?: return emptyMap()
|
|
||||||
return try {
|
return try {
|
||||||
val obj = JSONObject(json)
|
val obj = JSONObject(json)
|
||||||
obj.keys().asSequence().associateWith { key -> obj.getString(key) }
|
obj.keys().asSequence().associateWith { key -> obj.getString(key) }
|
||||||
|
|
@ -169,8 +204,7 @@ class StationRepository @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveCustomStations() {
|
private fun saveCustomStations() { val custom = stations.filter { isCustomStation(it.id) }
|
||||||
val custom = stations.filter { isCustomStation(it.id) }
|
|
||||||
val arr = JSONArray()
|
val arr = JSONArray()
|
||||||
custom.forEach { s ->
|
custom.forEach { s ->
|
||||||
arr.put(JSONObject().apply {
|
arr.put(JSONObject().apply {
|
||||||
|
|
@ -183,9 +217,26 @@ class StationRepository @Inject constructor(
|
||||||
prefs.edit().putString(KEY_CUSTOM_STATIONS, arr.toString()).apply()
|
prefs.edit().putString(KEY_CUSTOM_STATIONS, arr.toString()).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadHiddenIds(): Set<String> {
|
||||||
|
val json = prefs.getString(KEY_HIDDEN_STATIONS, null) ?: return emptySet()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
(0 until arr.length()).mapTo(mutableSetOf()) { i -> arr.getString(i) }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveHiddenIds(ids: Set<String>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
ids.forEach { arr.put(it) }
|
||||||
|
prefs.edit().putString(KEY_HIDDEN_STATIONS, arr.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_CUSTOM_STATIONS = "custom_stations"
|
private const val KEY_CUSTOM_STATIONS = "custom_stations"
|
||||||
private const val KEY_NAME_OVERRIDES = "station_name_overrides"
|
private const val KEY_NAME_OVERRIDES = "station_name_overrides"
|
||||||
private const val KEY_FAVORITE_STATIONS = "favorite_stations"
|
private const val KEY_FAVORITE_STATIONS = "favorite_stations"
|
||||||
|
private const val KEY_HIDDEN_STATIONS = "hidden_stations"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,34 @@ class StreamRepository @Inject constructor(
|
||||||
|
|
||||||
private var cache: List<StreamEntry>? = null
|
private var cache: List<StreamEntry>? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
ensureBuiltins()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media Monarchy ships as a built-in stream (talk, not music — it lives
|
||||||
|
* here instead of the Stations tab). Seeded exactly once per install so
|
||||||
|
* a user delete is respected and never resurrected.
|
||||||
|
*/
|
||||||
|
private fun ensureBuiltins() {
|
||||||
|
if (prefs.getBoolean(KEY_BUILTINS_SEEDED, false)) return
|
||||||
|
val current = getAll().toMutableList()
|
||||||
|
if (current.none { it.url == BUILTIN_MONARCHY_URL }) {
|
||||||
|
current.add(
|
||||||
|
0,
|
||||||
|
StreamEntry(
|
||||||
|
id = "media-monarchy",
|
||||||
|
name = "Media Monarchy",
|
||||||
|
url = BUILTIN_MONARCHY_URL,
|
||||||
|
source = "Built-in",
|
||||||
|
group = "Talk",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
save(current)
|
||||||
|
}
|
||||||
|
prefs.edit().putBoolean(KEY_BUILTINS_SEEDED, true).apply()
|
||||||
|
}
|
||||||
|
|
||||||
fun getAll(): List<StreamEntry> {
|
fun getAll(): List<StreamEntry> {
|
||||||
cache?.let { return it }
|
cache?.let { return it }
|
||||||
val raw = prefs.getString(key, "[]") ?: "[]"
|
val raw = prefs.getString(key, "[]") ?: "[]"
|
||||||
|
|
@ -61,4 +89,9 @@ class StreamRepository @Inject constructor(
|
||||||
cache = list
|
cache = list
|
||||||
prefs.edit().putString(key, json.encodeToString(streamEntriesSerializer, list)).apply()
|
prefs.edit().putString(key, json.encodeToString(streamEntriesSerializer, list)).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_BUILTINS_SEEDED = "builtins_seeded"
|
||||||
|
private const val BUILTIN_MONARCHY_URL = "http://216.158.245.68:8000/listen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
112
app/src/main/java/com/rada/data/wavlake/StationFeature.kt
Normal file
112
app/src/main/java/com/rada/data/wavlake/StationFeature.kt
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package com.rada.data.wavlake
|
||||||
|
|
||||||
|
import com.rada.domain.model.WavlakeTrack
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional, configurable featuring of Wavlake tracks alongside (never
|
||||||
|
* inside) live radio stations. Stations are live streams, so featuring is
|
||||||
|
* an opt-in shelf the user taps — it never replaces station content,
|
||||||
|
* never interrupts playback, and any API failure degrades to "no picks".
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class WavlakeStationSettings(
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
/** Probability a station view refreshes its picks (0.0–1.0). */
|
||||||
|
val featureProbability: Double = 0.1,
|
||||||
|
val maxFeaturedTracksPerHour: Int = 3,
|
||||||
|
val maxFeaturedTracksPerQueue: Int = 1,
|
||||||
|
val matchingMode: MatchingMode = MatchingMode.EXACT_OR_RELATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class MatchingMode { EXACT, RELATED, EXACT_OR_RELATED }
|
||||||
|
|
||||||
|
val defaultWavlakeStationSettings = WavlakeStationSettings()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Station genre -> exact Wavlake catalog genre names
|
||||||
|
* (https://github.com/wavlake/genre-list). Only names from that list are
|
||||||
|
* ever sent to the API — unknown names return HTTP 400 there.
|
||||||
|
*/
|
||||||
|
object WavlakeGenreMatcher {
|
||||||
|
private val related: Map<String, List<String>> = mapOf(
|
||||||
|
"Lofi" to listOf("Downtempo", "Ambient", "Electronic"),
|
||||||
|
"Synthwave" to listOf("Electro", "Electronica", "Electronic", "Electronic Rock"),
|
||||||
|
"EDM" to listOf(
|
||||||
|
"Dance/EDM", "House", "Jackin House", "Dubstep", "Techno", "Trance",
|
||||||
|
"Breakbeat", "Garage", "Hard Dance", "Jungle/Drum'n'bass", "Hi-NRG / Eurodance",
|
||||||
|
),
|
||||||
|
"NCS" to listOf(
|
||||||
|
"Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "House", "Dubstep",
|
||||||
|
"Jungle/Drum'n'bass", "Trance", "Techno",
|
||||||
|
),
|
||||||
|
"Electronic" to listOf("Electronic", "Electronica", "Downtempo", "Ambient"),
|
||||||
|
"Hip-Hop" to listOf("Hip-Hop/Rap", "Hip-Hop", "Rap"),
|
||||||
|
"Pop" to listOf("Pop", "Indie Pop"),
|
||||||
|
"Rock" to listOf("Rock", "Alternative Rock", "Indie Rock"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Station "A / B" strings use the same split convention as the app. */
|
||||||
|
fun splitStationGenres(genre: String): Pair<String, List<String>> {
|
||||||
|
val parts = genre.split(" / ").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
return (parts.firstOrNull() ?: "") to parts.drop(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact Wavlake genre names compatible with a station, per [MatchingMode]. */
|
||||||
|
fun compatibleGenres(
|
||||||
|
primary: String,
|
||||||
|
secondary: List<String> = emptyList(),
|
||||||
|
mode: MatchingMode = MatchingMode.EXACT_OR_RELATED,
|
||||||
|
): List<String> {
|
||||||
|
val out = LinkedHashSet<String>()
|
||||||
|
val keys = (listOf(primary) + secondary).map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
if (mode == MatchingMode.EXACT || mode == MatchingMode.EXACT_OR_RELATED) {
|
||||||
|
keys.filter { isWavlakeGenreName(it) }.forEach { out.add(it) }
|
||||||
|
}
|
||||||
|
if (mode == MatchingMode.RELATED || mode == MatchingMode.EXACT_OR_RELATED) {
|
||||||
|
keys.forEach { g -> related[g]?.let { out.addAll(it) } }
|
||||||
|
}
|
||||||
|
return out.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isWavlakeGenreName(name: String): Boolean =
|
||||||
|
related.values.any { it.contains(name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inputs for one featuring decision. All IDs are Wavlake track/artist IDs. */
|
||||||
|
data class FeatureSelection(
|
||||||
|
val candidates: List<WavlakeTrack>,
|
||||||
|
val settings: WavlakeStationSettings = defaultWavlakeStationSettings,
|
||||||
|
/** Track IDs already queued or playing — never duplicated. */
|
||||||
|
val excludedTrackIds: Set<String> = emptySet(),
|
||||||
|
/** Artist IDs blocked by the user. */
|
||||||
|
val blockedArtistIds: Set<String> = emptySet(),
|
||||||
|
/** Artist IDs featured recently — deprioritized to keep rotation fresh. */
|
||||||
|
val recentArtistIds: Set<String> = emptySet(),
|
||||||
|
/** How many tracks were featured in the last hour (cap gate). */
|
||||||
|
val featuredThisHour: Int = 0,
|
||||||
|
val random: Random = Random.Default,
|
||||||
|
)
|
||||||
|
|
||||||
|
object FeatureSelector {
|
||||||
|
fun select(input: FeatureSelection): List<WavlakeTrack> {
|
||||||
|
val s = input.settings
|
||||||
|
if (!s.enabled) return emptyList()
|
||||||
|
if (input.featuredThisHour >= s.maxFeaturedTracksPerHour) return emptyList()
|
||||||
|
if (s.maxFeaturedTracksPerQueue <= 0) return emptyList()
|
||||||
|
if (input.random.nextDouble() >= s.featureProbability.coerceIn(0.0, 1.0)) return emptyList()
|
||||||
|
|
||||||
|
val pool = input.candidates
|
||||||
|
.filter { it.audioUrl?.isNotBlank() == true }
|
||||||
|
.filterNot { it.id in input.excludedTrackIds }
|
||||||
|
.filterNot { it.artistId in input.blockedArtistIds }
|
||||||
|
.distinctBy { it.id }
|
||||||
|
if (pool.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
// Fresh artists first, stable order otherwise; then take the cap.
|
||||||
|
val fresh = pool.filterNot { it.artistId in input.recentArtistIds }
|
||||||
|
val ordered = (fresh.ifEmpty { pool }).shuffled(input.random)
|
||||||
|
return ordered.take(s.maxFeaturedTracksPerQueue)
|
||||||
|
}
|
||||||
|
}
|
||||||
227
app/src/main/java/com/rada/data/wavlake/WavlakeApi.kt
Normal file
227
app/src/main/java/com/rada/data/wavlake/WavlakeApi.kt
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
package com.rada.data.wavlake
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw DTOs mirror wavlake/developer-docs swagger.yaml exactly (all fields
|
||||||
|
* optional-tolerant; unknown keys ignored for forward compatibility).
|
||||||
|
* Base https://wavlake.com/api/v1. No authentication (the spec's
|
||||||
|
* securityDefinitions are commented out; /lnurl needs a Wavlake-issued
|
||||||
|
* appId and is intentionally NOT used — zaps go through the app's
|
||||||
|
* existing NIP-57 wallet flow).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── DTOs ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SearchResultDto(
|
||||||
|
val id: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val type: String? = null, // artist | album | track
|
||||||
|
val albumArtUrl: String? = null,
|
||||||
|
val artistArtUrl: String? = null,
|
||||||
|
val albumId: String? = null,
|
||||||
|
val albumTitle: String? = null,
|
||||||
|
val artistId: String? = null,
|
||||||
|
val artist: String? = null,
|
||||||
|
val duration: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TrackDto(
|
||||||
|
val id: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val albumTitle: String? = null,
|
||||||
|
val artist: String? = null,
|
||||||
|
val artistId: String? = null,
|
||||||
|
val albumId: String? = null,
|
||||||
|
val artistArtUrl: String? = null,
|
||||||
|
val albumArtUrl: String? = null,
|
||||||
|
val mediaUrl: String? = null,
|
||||||
|
val duration: Int? = null,
|
||||||
|
val releaseDate: String? = null,
|
||||||
|
val msatTotal: String? = null,
|
||||||
|
val artistNpub: String? = null,
|
||||||
|
val genre: String? = null,
|
||||||
|
val url: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CatalogDto(
|
||||||
|
val tracks: List<TrackDto> = emptyList(),
|
||||||
|
val nextCursor: String? = null,
|
||||||
|
val total: Int? = null,
|
||||||
|
val limit: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AlbumSummaryDto(
|
||||||
|
val id: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val albumArtUrl: String? = null,
|
||||||
|
val releaseDate: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ArtistDto(
|
||||||
|
val id: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val bio: String? = null,
|
||||||
|
val artistArtUrl: String? = null,
|
||||||
|
val albums: List<AlbumSummaryDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AlbumDto(
|
||||||
|
val id: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val artist: String? = null,
|
||||||
|
val artistUrl: String? = null,
|
||||||
|
val artistArtUrl: String? = null,
|
||||||
|
val albumArtUrl: String? = null,
|
||||||
|
val releaseDate: String? = null,
|
||||||
|
val tracks: List<TrackDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ErrorDto(val error: String? = null)
|
||||||
|
|
||||||
|
// ── Result ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sealed interface WavlakeResult<out T> {
|
||||||
|
data class Ok<T>(val value: T) : WavlakeResult<T>
|
||||||
|
/** API failure never throws — local radio keeps playing regardless. */
|
||||||
|
data class Failed(val kind: FailureKind, val message: String) : WavlakeResult<Nothing>
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class FailureKind { NETWORK, HTTP, RATE_LIMITED, PARSE, EMPTY, AUTH }
|
||||||
|
|
||||||
|
// ── Client ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class WavlakeApi(
|
||||||
|
private val baseUrl: String = "https://wavlake.com/api/v1",
|
||||||
|
private val client: OkHttpClient = defaultClient(),
|
||||||
|
) {
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
coerceInputValues = true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun search(term: String): WavlakeResult<List<SearchResultDto>> =
|
||||||
|
get("content/search", mapOf("term" to term)) { body ->
|
||||||
|
json.decodeFromString(
|
||||||
|
kotlinx.serialization.builtins.ListSerializer(SearchResultDto.serializer()),
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun rankings(
|
||||||
|
days: Int = 7,
|
||||||
|
genre: String? = null,
|
||||||
|
limit: Int = 25,
|
||||||
|
): WavlakeResult<List<TrackDto>> {
|
||||||
|
val params = mutableMapOf("sort" to "sats", "days" to days.toString(), "limit" to limit.toString())
|
||||||
|
if (!genre.isNullOrBlank()) params["genre"] = genre
|
||||||
|
return get("content/rankings", params) { body ->
|
||||||
|
json.decodeFromString(
|
||||||
|
kotlinx.serialization.builtins.ListSerializer(TrackDto.serializer()),
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun catalog(
|
||||||
|
limit: Int = 50,
|
||||||
|
cursor: String? = null,
|
||||||
|
artistId: String? = null,
|
||||||
|
genre: String? = null,
|
||||||
|
): WavlakeResult<CatalogDto> {
|
||||||
|
val params = mutableMapOf("limit" to limit.coerceIn(1, 100).toString())
|
||||||
|
if (!cursor.isNullOrBlank()) params["cursor"] = cursor
|
||||||
|
if (!artistId.isNullOrBlank()) params["artistId"] = artistId
|
||||||
|
if (!genre.isNullOrBlank()) params["genre"] = genre
|
||||||
|
return get("content/tracks", params) { body ->
|
||||||
|
json.decodeFromString(CatalogDto.serializer(), body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun track(trackId: String): WavlakeResult<TrackDto> =
|
||||||
|
get("content/track/$trackId", emptyMap()) { body ->
|
||||||
|
json.decodeFromString(TrackDto.serializer(), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun artist(artistId: String): WavlakeResult<ArtistDto> =
|
||||||
|
get("content/artist/$artistId", emptyMap()) { body ->
|
||||||
|
json.decodeFromString(ArtistDto.serializer(), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun album(albumId: String): WavlakeResult<AlbumDto> =
|
||||||
|
get("content/album/$albumId", emptyMap()) { body ->
|
||||||
|
json.decodeFromString(AlbumDto.serializer(), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> get(
|
||||||
|
path: String,
|
||||||
|
params: Map<String, String>,
|
||||||
|
parse: (String) -> T,
|
||||||
|
): WavlakeResult<T> = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
|
val url = try {
|
||||||
|
(baseUrl.trimEnd('/') + "/" + path).toHttpUrl().newBuilder()
|
||||||
|
.also { b -> params.forEach { (k, v) -> b.addQueryParameter(k, v) } }
|
||||||
|
.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return@withContext WavlakeResult.Failed(FailureKind.NETWORK, "Bad request URL.")
|
||||||
|
}
|
||||||
|
val response = try {
|
||||||
|
client.newCall(
|
||||||
|
Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("User-Agent", "SatsAmp/0.1 (wavlake catalog client)")
|
||||||
|
.build(),
|
||||||
|
).execute()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Offline, DNS, TLS, timeouts — all surface here, never thrown.
|
||||||
|
return@withContext WavlakeResult.Failed(FailureKind.NETWORK, "Network unavailable.")
|
||||||
|
}
|
||||||
|
response.use { r ->
|
||||||
|
if (r.code == 429) {
|
||||||
|
val retryAfter = r.header("Retry-After")?.toLongOrNull()
|
||||||
|
return@withContext WavlakeResult.Failed(
|
||||||
|
FailureKind.RATE_LIMITED,
|
||||||
|
"Rate limited" + (retryAfter?.let { " — retry in ${it}s" } ?: "."),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!r.isSuccessful) {
|
||||||
|
val serverMsg = runCatching {
|
||||||
|
json.decodeFromString(ErrorDto.serializer(), r.body?.string().orEmpty()).error
|
||||||
|
}.getOrNull()
|
||||||
|
val kind = if (r.code == 401 || r.code == 403) FailureKind.AUTH else FailureKind.HTTP
|
||||||
|
return@withContext WavlakeResult.Failed(kind, serverMsg ?: "Request failed (${r.code}).")
|
||||||
|
}
|
||||||
|
val body = try {
|
||||||
|
r.body?.string().orEmpty()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return@withContext WavlakeResult.Failed(FailureKind.NETWORK, "Empty response.")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
WavlakeResult.Ok(parse(body))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
WavlakeResult.Failed(FailureKind.PARSE, "Couldn't read the catalog response.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(20, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
441
app/src/main/java/com/rada/data/wavlake/WavlakeRepository.kt
Normal file
441
app/src/main/java/com/rada/data/wavlake/WavlakeRepository.kt
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
package com.rada.data.wavlake
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.rada.data.local.entity.SavedSongEntity
|
||||||
|
import com.rada.data.repository.LibraryRepository
|
||||||
|
import com.rada.domain.model.SOURCE_WAVLAKE
|
||||||
|
import com.rada.domain.model.WavlakeArtist
|
||||||
|
import com.rada.domain.model.WavlakeTrack
|
||||||
|
import com.rada.nostr.Nip19
|
||||||
|
import com.rada.nostr.NostrService
|
||||||
|
import com.rada.payments.hasValidLightningAddress
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.builtins.MapSerializer
|
||||||
|
import kotlinx.serialization.builtins.serializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.json.JSONObject
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wavlake catalog gateway (official public API, no auth — see [WavlakeApi]).
|
||||||
|
*
|
||||||
|
* Hard guarantees:
|
||||||
|
* - Every function returns normally; API trouble surfaces as
|
||||||
|
* [WavlakeResult.Failed] or empty lists — local radio never stops.
|
||||||
|
* - No secrets: nothing here needs credentials, so no .env is required.
|
||||||
|
* - The catalog exposes NO Lightning Address (only artist npub). Addresses
|
||||||
|
* resolve via the artist's Nostr kind-0 profile (lud16) and are validated
|
||||||
|
* with [hasValidLightningAddress] — the ONLY zap gate.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class WavlakeRepository @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val api: WavlakeApi,
|
||||||
|
private val nostr: NostrService,
|
||||||
|
private val library: LibraryRepository,
|
||||||
|
) {
|
||||||
|
private val prefs by lazy {
|
||||||
|
context.getSharedPreferences("wavlake", Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
// ── Normalization (pure top-level fns, JVM-testable) ─────────────────
|
||||||
|
|
||||||
|
suspend fun search(term: String): WavlakeResult<List<WavlakeSearchHit>> {
|
||||||
|
if (term.isBlank()) return WavlakeResult.Ok(emptyList())
|
||||||
|
return when (val r = api.search(term.trim())) {
|
||||||
|
is WavlakeResult.Ok -> WavlakeResult.Ok(
|
||||||
|
r.value.mapNotNull { dto ->
|
||||||
|
val id = dto.id?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||||
|
val name = dto.name?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||||
|
when (dto.type) {
|
||||||
|
"artist" -> WavlakeSearchHit.Artist(
|
||||||
|
WavlakeArtist(
|
||||||
|
id = id, name = name,
|
||||||
|
imageUrl = dto.artistArtUrl ?: dto.albumArtUrl,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"album" -> WavlakeSearchHit.Album(
|
||||||
|
id = id, title = name,
|
||||||
|
artist = dto.artist,
|
||||||
|
artworkUrl = dto.albumArtUrl,
|
||||||
|
)
|
||||||
|
else -> WavlakeSearchHit.Track(
|
||||||
|
WavlakeTrack(
|
||||||
|
id = id, title = name,
|
||||||
|
artistId = dto.artistId.orEmpty(),
|
||||||
|
artistName = dto.artist?.takeIf { it.isNotBlank() }
|
||||||
|
?: "Unknown Artist",
|
||||||
|
artworkUrl = dto.albumArtUrl ?: dto.artistArtUrl,
|
||||||
|
durationMs = dto.duration?.let { if (it > 0) it * 1000L else null },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.distinctBy { it.stableId() },
|
||||||
|
)
|
||||||
|
is WavlakeResult.Failed -> r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun rankings(
|
||||||
|
days: Int = 7,
|
||||||
|
genre: String? = null,
|
||||||
|
limit: Int = 25,
|
||||||
|
): WavlakeResult<List<WavlakeTrack>> =
|
||||||
|
when (val r = api.rankings(days.coerceIn(1, 90), genre, limit.coerceIn(1, 100))) {
|
||||||
|
is WavlakeResult.Ok -> WavlakeResult.Ok(
|
||||||
|
r.value.mapNotNull { normalizeWavlakeTrack(it) }.distinctBy { it.id },
|
||||||
|
)
|
||||||
|
is WavlakeResult.Failed -> r
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun artistDetail(artistId: String): WavlakeResult<WavlakeArtistDetail> {
|
||||||
|
val artist = when (val r = api.artist(artistId)) {
|
||||||
|
is WavlakeResult.Ok -> normalizeWavlakeArtist(r.value)
|
||||||
|
?: return WavlakeResult.Failed(FailureKind.EMPTY, "Artist has no usable metadata.")
|
||||||
|
is WavlakeResult.Failed -> return r
|
||||||
|
}
|
||||||
|
// The artist endpoint carries no genre; derive it from the tracks.
|
||||||
|
val tracks = when (val r = api.catalog(limit = 100, artistId = artistId)) {
|
||||||
|
is WavlakeResult.Ok -> r.value.tracks.mapNotNull { normalizeWavlakeTrack(it) }
|
||||||
|
is WavlakeResult.Failed -> emptyList() // tracks optional; artist still shown
|
||||||
|
}.distinctBy { it.id }
|
||||||
|
val genres = tracks.mapNotNull { it.genre }.distinct()
|
||||||
|
return WavlakeResult.Ok(
|
||||||
|
WavlakeArtistDetail(
|
||||||
|
artist = artist.copy(
|
||||||
|
genre = genres.firstOrNull(),
|
||||||
|
genres = genres,
|
||||||
|
imageUrl = artist.imageUrl
|
||||||
|
?: tracks.firstNotNullOfOrNull { it.artworkUrl },
|
||||||
|
),
|
||||||
|
tracks = tracks,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun albumTracks(albumId: String): WavlakeResult<List<WavlakeTrack>> =
|
||||||
|
when (val r = api.album(albumId)) {
|
||||||
|
is WavlakeResult.Ok -> WavlakeResult.Ok(
|
||||||
|
r.value.tracks.mapNotNull { normalizeWavlakeTrack(it) }.distinctBy { it.id },
|
||||||
|
)
|
||||||
|
is WavlakeResult.Failed -> r
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun trackDetail(trackId: String): WavlakeResult<WavlakeTrack> =
|
||||||
|
when (val r = api.track(trackId)) {
|
||||||
|
is WavlakeResult.Ok ->
|
||||||
|
normalizeWavlakeTrack(r.value)?.let { WavlakeResult.Ok(it) }
|
||||||
|
?: WavlakeResult.Failed(FailureKind.EMPTY, "Track has no usable metadata.")
|
||||||
|
is WavlakeResult.Failed -> r
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback for search-index IDs that no longer resolve (stale entries
|
||||||
|
* 400 on /content/track): match by exact title inside the artist's
|
||||||
|
* catalog page. Same artist + same title = the same song.
|
||||||
|
*/
|
||||||
|
suspend fun matchArtistTrack(artistId: String, title: String): WavlakeTrack? {
|
||||||
|
if (artistId.isBlank() || title.isBlank()) return null
|
||||||
|
return when (val r = api.catalog(limit = 100, artistId = artistId)) {
|
||||||
|
is WavlakeResult.Ok -> r.value.tracks
|
||||||
|
.mapNotNull { normalizeWavlakeTrack(it) }
|
||||||
|
.firstOrNull {
|
||||||
|
it.title.equals(title, ignoreCase = true) &&
|
||||||
|
it.audioUrl?.isNotBlank() == true
|
||||||
|
}
|
||||||
|
is WavlakeResult.Failed -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lightning Address (Nostr kind-0 lud16 — the ONLY zap source) ─────
|
||||||
|
|
||||||
|
private val addressCache = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Addresses the user entered themselves (stored on successful zap).
|
||||||
|
* These are real, validated addresses — remembering them is what lets
|
||||||
|
* the bolt appear for artists the catalog exposes no npub for.
|
||||||
|
*/
|
||||||
|
fun getKnownAddress(artistId: String): String? {
|
||||||
|
val raw = prefs.getString(KEY_KNOWN_PREFIX + artistId, null) ?: return null
|
||||||
|
return raw.takeIf { hasValidLightningAddress(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rememberAddress(artistId: String, address: String): Boolean {
|
||||||
|
if (artistId.isBlank()) return false
|
||||||
|
val clean = address.trim()
|
||||||
|
if (!hasValidLightningAddress(clean)) return false
|
||||||
|
prefs.edit().putString(KEY_KNOWN_PREFIX + artistId, clean).apply()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort lud16 for an artist npub (bech32 or hex). Null when unknown. */
|
||||||
|
suspend fun resolveLightningAddress(artistId: String?, artistNpub: String?): String? {
|
||||||
|
if (!artistId.isNullOrBlank()) {
|
||||||
|
getKnownAddress(artistId)?.let { return it }
|
||||||
|
}
|
||||||
|
val npub = artistNpub?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
addressCache[npub]?.let { return it }
|
||||||
|
val hex = npubToHex(npub) ?: return null
|
||||||
|
// Mobile relays can be slow; one retry on miss. Misses only log —
|
||||||
|
// the strict gate simply shows no bolt.
|
||||||
|
var lud16: String? = null
|
||||||
|
repeat(2) {
|
||||||
|
lud16 = withTimeoutOrNull(12_000) {
|
||||||
|
nostr.getProfile(hex).firstOrNull { it != null }?.let { ev ->
|
||||||
|
runCatching {
|
||||||
|
JSONObject(ev!!.content).optString("lud16").takeIf { it.isNotBlank() }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lud16 != null) return@repeat
|
||||||
|
}
|
||||||
|
val valid = lud16?.takeIf { hasValidLightningAddress(it) }
|
||||||
|
if (valid == null) {
|
||||||
|
android.util.Log.w("Wavlake", "No lud16 for artist npub ${hex.take(12)}…")
|
||||||
|
} else {
|
||||||
|
addressCache[npub] = valid
|
||||||
|
}
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun npubToHex(npub: String): String? {
|
||||||
|
val v = npub.trim()
|
||||||
|
if (v.startsWith("npub1")) {
|
||||||
|
return runCatching {
|
||||||
|
val (hrp, bytes) = Nip19.decode(v) ?: return null
|
||||||
|
if (hrp != "npub" || bytes.size != 32) return null
|
||||||
|
bytes.joinToString("") { "%02x".format(it) }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
if (v.length == 64 && v.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) return v.lowercase()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Import (streamable library entries — no downloads) ───────────────
|
||||||
|
|
||||||
|
sealed interface ImportOutcome {
|
||||||
|
data class Imported(val songId: Long) : ImportOutcome
|
||||||
|
data class AlreadyInLibrary(val songId: Long) : ImportOutcome
|
||||||
|
data class Unplayable(val reason: String) : ImportOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Imports a Wavlake track as a streamable library entry (dedupe-safe). */
|
||||||
|
suspend fun importTrack(track: WavlakeTrack): ImportOutcome {
|
||||||
|
val audioUrl = track.audioUrl?.takeIf { it.isNotBlank() }
|
||||||
|
?: return ImportOutcome.Unplayable("No playable audio for this track.")
|
||||||
|
library.getByPath(audioUrl)?.let { return ImportOutcome.AlreadyInLibrary(it.id) }
|
||||||
|
library.getByArtistTitle(track.artistName, track.title)?.let {
|
||||||
|
return ImportOutcome.AlreadyInLibrary(it.id)
|
||||||
|
}
|
||||||
|
val id = library.insert(
|
||||||
|
SavedSongEntity(
|
||||||
|
fileName = "${track.artistName} - ${track.title}.mp3",
|
||||||
|
filePath = audioUrl,
|
||||||
|
artist = track.artistName,
|
||||||
|
title = track.title,
|
||||||
|
album = track.genre ?: "Wavlake",
|
||||||
|
stationName = "Wavlake",
|
||||||
|
durationMs = track.durationMs ?: 0,
|
||||||
|
fileSize = 0,
|
||||||
|
format = "mp3",
|
||||||
|
source = SOURCE_WAVLAKE,
|
||||||
|
sourceUrl = track.sourceUrl.orEmpty(),
|
||||||
|
artworkUrl = track.artworkUrl.orEmpty(),
|
||||||
|
wavlakeArtistId = track.artistId,
|
||||||
|
wavlakeArtistNpub = track.artistNpubHex.orEmpty(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ImportOutcome.Imported(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Featuring (genre-matched, opt-in shelf — never auto-inserted) ────
|
||||||
|
|
||||||
|
fun settings(): WavlakeStationSettings = runCatching {
|
||||||
|
prefs.getString(KEY_SETTINGS, null)?.let {
|
||||||
|
Json.decodeFromString(WavlakeStationSettings.serializer(), it)
|
||||||
|
}
|
||||||
|
}.getOrNull() ?: defaultWavlakeStationSettings
|
||||||
|
|
||||||
|
fun saveSettings(settings: WavlakeStationSettings) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_SETTINGS, Json.encodeToString(WavlakeStationSettings.serializer(), settings))
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isStationEnabled(stationId: String): Boolean {
|
||||||
|
if (!settings().enabled) return false
|
||||||
|
val map = stationMap()
|
||||||
|
return map[stationId] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStationEnabled(stationId: String, enabled: Boolean) {
|
||||||
|
val map = stationMap().toMutableMap()
|
||||||
|
map[stationId] = enabled
|
||||||
|
prefs.edit().putString(KEY_STATION_MAP, Json.encodeToString(mapSerializer(), map)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun blockedArtistIds(): Set<String> = runCatching {
|
||||||
|
prefs.getString(KEY_BLOCKED, null)?.let {
|
||||||
|
Json.decodeFromString(ListSerializer(String.serializer()), it)
|
||||||
|
}.orEmpty().toSet()
|
||||||
|
}.getOrDefault(emptySet())
|
||||||
|
|
||||||
|
fun setArtistBlocked(artistId: String, blocked: Boolean) {
|
||||||
|
val set = blockedArtistIds().toMutableSet()
|
||||||
|
if (blocked) set.add(artistId) else set.remove(artistId)
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_BLOCKED, Json.encodeToString(ListSerializer(String.serializer()), set.toList()))
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stationMap(): Map<String, Boolean> = runCatching {
|
||||||
|
prefs.getString(KEY_STATION_MAP, null)?.let {
|
||||||
|
Json.decodeFromString(mapSerializer(), it)
|
||||||
|
}
|
||||||
|
}.getOrNull().orEmpty()
|
||||||
|
|
||||||
|
private fun mapSerializer() =
|
||||||
|
MapSerializer(String.serializer(), Boolean.serializer())
|
||||||
|
|
||||||
|
private data class FeatureEvent(val atMs: Long, val artistId: String)
|
||||||
|
|
||||||
|
private fun recentEvents(): List<FeatureEvent> {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
return runCatching {
|
||||||
|
prefs.getString(KEY_FEATURED, null)?.let { raw ->
|
||||||
|
Json.decodeFromString(
|
||||||
|
ListSerializer(FeatureEventSer.serializer()),
|
||||||
|
raw,
|
||||||
|
).map { FeatureEvent(it.atMs, it.artistId) }
|
||||||
|
}
|
||||||
|
}.getOrNull().orEmpty().filter { now - it.atMs < 3_600_000L }
|
||||||
|
}
|
||||||
|
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
private data class FeatureEventSer(val atMs: Long, val artistId: String)
|
||||||
|
|
||||||
|
private fun recordFeatured(artistIds: List<String>) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val updated = (recentEvents() + artistIds.map { FeatureEvent(now, it) })
|
||||||
|
.filter { now - it.atMs < 3_600_000L }
|
||||||
|
prefs.edit().putString(
|
||||||
|
KEY_FEATURED,
|
||||||
|
Json.encodeToString(
|
||||||
|
ListSerializer(FeatureEventSer.serializer()),
|
||||||
|
updated.map { FeatureEventSer(it.atMs, it.artistId) },
|
||||||
|
),
|
||||||
|
).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genre-matched Wavlake picks for a station. Network-bound, failure-safe
|
||||||
|
* (empty on any trouble), and purely advisory — the caller decides
|
||||||
|
* whether to show them; nothing here touches playback or the queue.
|
||||||
|
*/
|
||||||
|
suspend fun featuredForStation(
|
||||||
|
stationId: String,
|
||||||
|
stationGenre: String,
|
||||||
|
excludedTrackIds: Set<String> = emptySet(),
|
||||||
|
): List<WavlakeTrack> = coroutineScope {
|
||||||
|
val settings = settings()
|
||||||
|
if (!isStationEnabled(stationId)) return@coroutineScope emptyList()
|
||||||
|
val (primary, secondary) = WavlakeGenreMatcher.splitStationGenres(stationGenre)
|
||||||
|
val genres = WavlakeGenreMatcher.compatibleGenres(primary, secondary, settings.matchingMode)
|
||||||
|
if (genres.isEmpty()) return@coroutineScope emptyList()
|
||||||
|
|
||||||
|
val events = recentEvents()
|
||||||
|
val perGenre = genres.take(3).map { genre ->
|
||||||
|
async {
|
||||||
|
when (val r = api.rankings(days = 30, genre = genre, limit = 25)) {
|
||||||
|
is WavlakeResult.Ok -> r.value.mapNotNull { normalizeWavlakeTrack(it) }
|
||||||
|
is WavlakeResult.Failed -> emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.awaitAll().flatten().distinctBy { it.id }
|
||||||
|
|
||||||
|
val picked = FeatureSelector.select(
|
||||||
|
FeatureSelection(
|
||||||
|
candidates = perGenre,
|
||||||
|
settings = settings,
|
||||||
|
excludedTrackIds = excludedTrackIds,
|
||||||
|
blockedArtistIds = blockedArtistIds(),
|
||||||
|
recentArtistIds = events.map { it.artistId }.toSet(),
|
||||||
|
featuredThisHour = events.size,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (picked.isNotEmpty()) recordFeatured(picked.map { it.artistId })
|
||||||
|
picked
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_SETTINGS = "wavlake_settings"
|
||||||
|
private const val KEY_STATION_MAP = "wavlake_station_enabled"
|
||||||
|
private const val KEY_BLOCKED = "wavlake_blocked_artists"
|
||||||
|
private const val KEY_FEATURED = "wavlake_featured_hour"
|
||||||
|
private const val KEY_KNOWN_PREFIX = "wavlake_addr_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface WavlakeSearchHit { fun stableId(): String
|
||||||
|
data class Artist(val artist: WavlakeArtist) : WavlakeSearchHit {
|
||||||
|
override fun stableId() = "artist:${artist.id}"
|
||||||
|
}
|
||||||
|
data class Album(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val artist: String?,
|
||||||
|
val artworkUrl: String?,
|
||||||
|
) : WavlakeSearchHit {
|
||||||
|
override fun stableId() = "album:$id"
|
||||||
|
}
|
||||||
|
data class Track(val track: WavlakeTrack) : WavlakeSearchHit {
|
||||||
|
override fun stableId() = "track:${track.id}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class WavlakeArtistDetail(
|
||||||
|
val artist: WavlakeArtist,
|
||||||
|
val tracks: List<WavlakeTrack>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Pure DTO normalization (null-tolerant, JVM-testable). */
|
||||||
|
fun normalizeWavlakeTrack(dto: TrackDto): WavlakeTrack? {
|
||||||
|
val id = dto.id?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val title = dto.title?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val audioUrl = dto.mediaUrl?.takeIf { it.isNotBlank() }
|
||||||
|
return WavlakeTrack(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
artistId = dto.artistId?.takeIf { it.isNotBlank() }.orEmpty(),
|
||||||
|
artistName = dto.artist?.takeIf { it.isNotBlank() } ?: "Unknown Artist",
|
||||||
|
genre = dto.genre?.takeIf { it.isNotBlank() },
|
||||||
|
genres = listOfNotNull(dto.genre?.takeIf { it.isNotBlank() }),
|
||||||
|
artworkUrl = dto.albumArtUrl?.takeIf { it.isNotBlank() }
|
||||||
|
?: dto.artistArtUrl?.takeIf { it.isNotBlank() },
|
||||||
|
durationMs = dto.duration?.let { if (it > 0) it * 1000L else null },
|
||||||
|
audioUrl = audioUrl,
|
||||||
|
sourceUrl = dto.url?.takeIf { it.isNotBlank() },
|
||||||
|
artistNpubHex = dto.artistNpub?.takeIf { it.isNotBlank() },
|
||||||
|
albumId = dto.albumId?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure DTO normalization (null-tolerant, JVM-testable). */
|
||||||
|
fun normalizeWavlakeArtist(dto: ArtistDto): WavlakeArtist? {
|
||||||
|
val id = dto.id?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return WavlakeArtist(
|
||||||
|
id = id,
|
||||||
|
name = dto.name?.takeIf { it.isNotBlank() } ?: "Unknown Artist",
|
||||||
|
imageUrl = dto.artistArtUrl?.takeIf { it.isNotBlank() },
|
||||||
|
bio = dto.bio?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
104
app/src/main/java/com/rada/data/wavlake/WavlakeZap.kt
Normal file
104
app/src/main/java/com/rada/data/wavlake/WavlakeZap.kt
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
package com.rada.data.wavlake
|
||||||
|
|
||||||
|
import com.rada.nostr.MusicKinds
|
||||||
|
import com.rada.nostr.NostrEvent
|
||||||
|
import com.rada.nostr.UnsignedEvent
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wavlake's official track-zap protocol, decoded from their MIT-licensed
|
||||||
|
* mobile client (wavlake/mobile: useZapContent + utils/nostr.ts — patterns
|
||||||
|
* only, no copied code):
|
||||||
|
*
|
||||||
|
* - Recipient is the PLATFORM key ([WAVLAKE_FEED_PUBKEY]); Wavlake splits
|
||||||
|
* downstream. No artist address or npub is required.
|
||||||
|
* - The NIP-57 zap request carries an `a` tag with the track's Nostr
|
||||||
|
* address (`32123:<feed-pubkey>:<trackId>`), a `timestamp` tag, and three
|
||||||
|
* podcast `i` tags whose GUIDs are UUIDv5 (RFC 4122) of the feed URLs.
|
||||||
|
* - `GET https://www.wavlake.com/api/zap?amount={msats}&nostr={eventJson}`
|
||||||
|
* returns LUD-06-style `{pr}` (or `{status:"ERROR"}` / `{success:false}`).
|
||||||
|
*
|
||||||
|
* This is what makes every catalog track payable even when the artist
|
||||||
|
* exposes no Lightning Address anywhere reachable.
|
||||||
|
*/
|
||||||
|
object WavlakeZap {
|
||||||
|
const val FEED_PUBKEY =
|
||||||
|
"7759fb24cec56fc57550754ca8f6d2c60183da2537c8f38108fdf283b20a0e58"
|
||||||
|
const val TRACK_KIND = 32123
|
||||||
|
const val RELAY = "wss://relay.wavlake.com/"
|
||||||
|
const val ZAP_ENDPOINT = "https://www.wavlake.com/api/zap"
|
||||||
|
const val ZAP_HOST = "www.wavlake.com"
|
||||||
|
|
||||||
|
/** UUID namespace from the official client (podcast feed GUIDs). */
|
||||||
|
val FEED_NAMESPACE: UUID = UUID.fromString("ead4c236-bf58-58c6-a2c6-a6b28d128cb6")
|
||||||
|
|
||||||
|
fun feedUrl(albumId: String): String = "wavlake.com/feed/music/$albumId"
|
||||||
|
|
||||||
|
/** RFC 4122 §4.3 UUIDv5 (SHA-1). Java only ships v3/v4. */
|
||||||
|
fun uuidV5(namespace: UUID, name: String): UUID {
|
||||||
|
val md = MessageDigest.getInstance("SHA-1")
|
||||||
|
val ns = ByteArray(16).also {
|
||||||
|
val bb = java.nio.ByteBuffer.wrap(it)
|
||||||
|
bb.putLong(namespace.mostSignificantBits)
|
||||||
|
bb.putLong(namespace.leastSignificantBits)
|
||||||
|
}
|
||||||
|
val hash = md.digest(ns + name.toByteArray(Charsets.UTF_8))
|
||||||
|
hash[6] = ((hash[6].toInt() and 0x0f) or 0x50).toByte() // version 5
|
||||||
|
hash[8] = ((hash[8].toInt() and 0x3f) or 0x80).toByte() // variant 10
|
||||||
|
val bb = java.nio.ByteBuffer.wrap(hash)
|
||||||
|
return UUID(bb.long, bb.long)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun trackAddressPointer(trackId: String): String = "$TRACK_KIND:$FEED_PUBKEY:$trackId"
|
||||||
|
|
||||||
|
fun itemTags(trackId: String, albumId: String): List<List<String>> {
|
||||||
|
val feedGuid = uuidV5(FEED_NAMESPACE, feedUrl(albumId)).toString()
|
||||||
|
return listOf(
|
||||||
|
listOf("i", "podcast:item:guid:$trackId"),
|
||||||
|
listOf("i", "podcast:guid:$feedGuid"),
|
||||||
|
listOf("i", "podcast:publisher:guid:$feedGuid"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsigned NIP-57 zap request for a track. The caller signs (identity
|
||||||
|
* key preferred, ephemeral per NIP-57 otherwise) — see ZapService.
|
||||||
|
*/
|
||||||
|
fun buildZapRequest(
|
||||||
|
trackId: String,
|
||||||
|
albumId: String,
|
||||||
|
amountSats: Long,
|
||||||
|
comment: String,
|
||||||
|
relays: List<String>,
|
||||||
|
signerPubkeyHex: String,
|
||||||
|
): UnsignedEvent {
|
||||||
|
require(trackId.isNotBlank() && albumId.isNotBlank())
|
||||||
|
require(amountSats > 0)
|
||||||
|
val tags = mutableListOf(
|
||||||
|
listOf("p", FEED_PUBKEY),
|
||||||
|
listOf("relays", RELAY) + relays.take(2),
|
||||||
|
listOf("amount", (amountSats * 1000).toString()),
|
||||||
|
listOf("a", trackAddressPointer(trackId), RELAY),
|
||||||
|
listOf("timestamp", (System.currentTimeMillis() / 1000).toString()),
|
||||||
|
)
|
||||||
|
tags.addAll(itemTags(trackId, albumId))
|
||||||
|
return NostrEvent.unsigned(
|
||||||
|
pubkey = signerPubkeyHex,
|
||||||
|
kind = MusicKinds.ZAP_REQUEST,
|
||||||
|
content = comment,
|
||||||
|
tags = tags,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invoiceUrl(amountSats: Long, zapRequestJson: String): String {
|
||||||
|
val encoded = java.net.URLEncoder.encode(zapRequestJson, "UTF-8")
|
||||||
|
return "$ZAP_ENDPOINT?amount=${amountSats * 1000}&nostr=$encoded"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strict invoice sanity: mainnet bolt11 only, from the fixed endpoint. */
|
||||||
|
fun isPlausibleInvoice(bolt11: String): Boolean {
|
||||||
|
val v = bolt11.trim().lowercase()
|
||||||
|
return v.startsWith("lnbc") && v.length > 20 && v.none { it.isWhitespace() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import com.rada.data.local.dao.SavedSongDao
|
||||||
import com.rada.data.repository.LibraryRepository
|
import com.rada.data.repository.LibraryRepository
|
||||||
import com.rada.data.repository.PlaylistRepository
|
import com.rada.data.repository.PlaylistRepository
|
||||||
import com.rada.data.repository.StationRepository
|
import com.rada.data.repository.StationRepository
|
||||||
|
import com.rada.data.wavlake.WavlakeApi
|
||||||
import com.rada.player.PlaybackManager
|
import com.rada.player.PlaybackManager
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
|
|
@ -61,4 +62,8 @@ object AppModule {
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
libraryRepository: LibraryRepository,
|
libraryRepository: LibraryRepository,
|
||||||
): PlaybackManager = PlaybackManager(context, libraryRepository)
|
): PlaybackManager = PlaybackManager(context, libraryRepository)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideWavlakeApi(): WavlakeApi = WavlakeApi()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
app/src/main/java/com/rada/di/PaymentsModule.kt
Normal file
24
app/src/main/java/com/rada/di/PaymentsModule.kt
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.rada.di
|
||||||
|
|
||||||
|
import com.rada.payments.NwcProvider
|
||||||
|
import com.rada.payments.PaymentService
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object PaymentsModule {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider priority list. NWC first (user-connected wallet); LNURL and
|
||||||
|
* Cashu providers land in later phases and just append here — no other
|
||||||
|
* call site changes.
|
||||||
|
*/
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providePaymentService(nwc: NwcProvider): PaymentService =
|
||||||
|
PaymentService(providers = { listOf(nwc) })
|
||||||
|
}
|
||||||
52
app/src/main/java/com/rada/domain/model/WavlakeModels.kt
Normal file
52
app/src/main/java/com/rada/domain/model/WavlakeModels.kt
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package com.rada.domain.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized Wavlake catalog models. Field names follow the official
|
||||||
|
* Wavlake Developer API (wavlake/developer-docs, swagger.yaml, MIT):
|
||||||
|
* search/rankings/tracks/track/artist/album responses. No invented fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const val SOURCE_WAVLAKE = "wavlake"
|
||||||
|
const val SOURCE_LOCAL = "local"
|
||||||
|
const val SOURCE_OTHER = "other"
|
||||||
|
|
||||||
|
data class WavlakeArtist(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val genre: String? = null,
|
||||||
|
val genres: List<String> = emptyList(),
|
||||||
|
val imageUrl: String? = null,
|
||||||
|
val bio: String? = null,
|
||||||
|
val source: String = SOURCE_WAVLAKE,
|
||||||
|
val sourceUrl: String? = null,
|
||||||
|
/** Lightning Address ONLY (e.g. artist@example.com). Never LNURL/lud06/lud16/npub here. */
|
||||||
|
val lightningAddress: String? = null,
|
||||||
|
/** Nostr pubkey hex, when the API exposes it (used for the NIP-57 zap flow). */
|
||||||
|
val npubHex: String? = null,
|
||||||
|
) {
|
||||||
|
/** Derived ONLY from [lightningAddress]. See [com.rada.payments.hasValidLightningAddress]. */
|
||||||
|
val canZap: Boolean
|
||||||
|
get() = com.rada.payments.hasValidLightningAddress(lightningAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class WavlakeTrack(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val artistId: String,
|
||||||
|
val artistName: String,
|
||||||
|
val genre: String? = null,
|
||||||
|
val genres: List<String> = emptyList(),
|
||||||
|
val artworkUrl: String? = null,
|
||||||
|
val durationMs: Long? = null,
|
||||||
|
val audioUrl: String? = null,
|
||||||
|
val source: String = SOURCE_WAVLAKE,
|
||||||
|
val sourceUrl: String? = null,
|
||||||
|
/** Nostr pubkey hex of the artist, when exposed (zap flow). */
|
||||||
|
val artistNpubHex: String? = null,
|
||||||
|
/** Album GUID (official track-zap `i`-tags + parent content id). */
|
||||||
|
val albumId: String? = null,
|
||||||
|
) {
|
||||||
|
/** Officially payable: the track-zap endpoint only needs track + album ids. */
|
||||||
|
val isZapPayable: Boolean
|
||||||
|
get() = id.isNotBlank() && !albumId.isNullOrBlank() && !audioUrl.isNullOrBlank()
|
||||||
|
}
|
||||||
156
app/src/main/java/com/rada/nostr/Bech32.kt
Normal file
156
app/src/main/java/com/rada/nostr/Bech32.kt
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
package com.rada.nostr
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BIP-173 bech32 + NIP-19 TLV entities (npub, nsec, nprofile, nevent, naddr).
|
||||||
|
* Pure Kotlin, no external deps.
|
||||||
|
*/
|
||||||
|
object Bech32 {
|
||||||
|
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
|
private val GENERATOR = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)
|
||||||
|
|
||||||
|
private fun polymod(values: IntArray): Int {
|
||||||
|
var chk = 1
|
||||||
|
for (v in values) {
|
||||||
|
val b = chk ushr 25
|
||||||
|
chk = ((chk and 0x1ffffff) shl 5) xor v
|
||||||
|
for (i in 0..4) if (((b ushr i) and 1) != 0) chk = chk xor GENERATOR[i]
|
||||||
|
}
|
||||||
|
return chk
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hrpExpand(hrp: String): IntArray {
|
||||||
|
val ret = IntArray(hrp.length * 2 + 1)
|
||||||
|
for (i in hrp.indices) ret[i] = hrp[i].code ushr 5
|
||||||
|
// ret[hrp.length] stays 0 (separator)
|
||||||
|
for (i in hrp.indices) ret[hrp.length + 1 + i] = hrp[i].code and 31
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verifyChecksum(hrp: String, data: IntArray): Boolean =
|
||||||
|
polymod(hrpExpand(hrp) + data) == 1
|
||||||
|
|
||||||
|
private fun createChecksum(hrp: String, data: IntArray): IntArray {
|
||||||
|
val values = hrpExpand(hrp) + data + IntArray(6)
|
||||||
|
val mod = polymod(values) xor 1
|
||||||
|
return IntArray(6) { (mod ushr (5 * (5 - it))) and 31 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encode(hrp: String, data5bit: IntArray): String {
|
||||||
|
val combined = data5bit + createChecksum(hrp, data5bit)
|
||||||
|
return hrp + "1" + combined.joinToString("") { CHARSET[it].toString() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns (hrp, data5bit) or null on invalid input. */
|
||||||
|
fun decode(bech: String): Pair<String, IntArray>? {
|
||||||
|
val lower = bech.lowercase()
|
||||||
|
val pos = lower.lastIndexOf('1')
|
||||||
|
if (pos < 1 || pos + 7 > lower.length) return null
|
||||||
|
val hrp = lower.substring(0, pos)
|
||||||
|
val data = IntArray(lower.length - pos - 1)
|
||||||
|
for (i in data.indices) {
|
||||||
|
val idx = CHARSET.indexOf(lower[pos + 1 + i])
|
||||||
|
if (idx == -1) return null
|
||||||
|
data[i] = idx
|
||||||
|
}
|
||||||
|
if (!verifyChecksum(hrp, data)) return null
|
||||||
|
return hrp to data.dropLast(6).toIntArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun convertBits(data: ByteArray, fromBits: Int, toBits: Int, pad: Boolean): IntArray {
|
||||||
|
var acc = 0
|
||||||
|
var bits = 0
|
||||||
|
val out = ArrayList<Int>()
|
||||||
|
val maxv = (1 shl toBits) - 1
|
||||||
|
for (b in data) {
|
||||||
|
val value = b.toInt() and 0xff
|
||||||
|
acc = (acc shl fromBits) or value
|
||||||
|
bits += fromBits
|
||||||
|
while (bits >= toBits) {
|
||||||
|
bits -= toBits
|
||||||
|
out.add((acc ushr bits) and maxv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pad && bits > 0) out.add((acc shl (toBits - bits)) and maxv)
|
||||||
|
return out.toIntArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun convertBitsBack(data5: IntArray, toBits: Int = 8): ByteArray {
|
||||||
|
var acc = 0
|
||||||
|
var bits = 0
|
||||||
|
val out = ArrayList<Byte>()
|
||||||
|
val maxv = (1 shl toBits) - 1
|
||||||
|
for (value in data5) {
|
||||||
|
acc = (acc shl 5) or value
|
||||||
|
bits += 5
|
||||||
|
if (bits >= toBits) {
|
||||||
|
bits -= toBits
|
||||||
|
out.add(((acc ushr bits) and maxv).toByte())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NIP-19 entities. */
|
||||||
|
object Nip19 {
|
||||||
|
fun npub(pubkeyHex: String): String =
|
||||||
|
Bech32.encode("npub", Bech32.convertBits(hexToBytes(pubkeyHex), 8, 5, true))
|
||||||
|
|
||||||
|
fun nsec(privkeyHex: String): String =
|
||||||
|
Bech32.encode("nsec", Bech32.convertBits(hexToBytes(privkeyHex), 8, 5, true))
|
||||||
|
|
||||||
|
/** nprofile = TLV {0: pubkey, 1: relay hints} */
|
||||||
|
fun nprofile(pubkeyHex: String, relays: List<String>): String {
|
||||||
|
val tlv = ArrayList<Byte>()
|
||||||
|
fun tag(type: Int, payload: ByteArray) {
|
||||||
|
tlv.add(type.toByte()); tlv.add(payload.size.toByte()); tlv.addAll(payload.toList())
|
||||||
|
}
|
||||||
|
tag(0, hexToBytes(pubkeyHex))
|
||||||
|
relays.take(3).forEach { tag(1, it.toByteArray()) }
|
||||||
|
return Bech32.encode("nprofile",
|
||||||
|
Bech32.convertBits(tlv.toByteArray(), 8, 5, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** nevent = TLV {1: event_id, 3: relay, [5 kind]} */
|
||||||
|
fun nevent(eventIdHex: String, relays: List<String> = emptyList(), kind: Int? = null): String {
|
||||||
|
val tlv = ArrayList<Byte>()
|
||||||
|
fun tag(type: Int, payload: ByteArray) {
|
||||||
|
tlv.add(type.toByte()); tlv.add(payload.size.toByte()); tlv.addAll(payload.toList())
|
||||||
|
}
|
||||||
|
tag(1, hexToBytes(eventIdHex))
|
||||||
|
relays.take(3).forEach { tag(3, it.toByteArray()) }
|
||||||
|
kind?.let { tag(5, byteArrayOf((it shr 8).toByte(), (it and 0xff).toByte())) }
|
||||||
|
return Bech32.encode("nevent", Bech32.convertBits(tlv.toByteArray(), 8, 5, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** naddr = TLV {0: identifier(d), 1: relays, 2: pubkey, [3: kind]} — for 3044x music. */
|
||||||
|
fun naddr(dTag: String, pubkeyHex: String, kind: Int, relays: List<String> = emptyList()): String {
|
||||||
|
val tlv = ArrayList<Byte>()
|
||||||
|
fun tag(type: Int, payload: ByteArray) {
|
||||||
|
tlv.add(type.toByte()); tlv.add(payload.size.toByte()); tlv.addAll(payload.toList())
|
||||||
|
}
|
||||||
|
tag(0, dTag.toByteArray())
|
||||||
|
relays.take(3).forEach { tag(3, it.toByteArray()) }
|
||||||
|
tag(2, hexToBytes(pubkeyHex))
|
||||||
|
tag(3, byteArrayOf((kind shr 8).toByte(), (kind and 0xff).toByte()))
|
||||||
|
return Bech32.encode("naddr", Bech32.convertBits(tlv.toByteArray(), 8, 5, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decode(bech: String): Pair<String, ByteArray>? {
|
||||||
|
val (hrp, data5) = Bech32.decode(bech) ?: return null
|
||||||
|
return hrp to Bech32.convertBitsBack(data5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
fun hexToBytes(hex: String): ByteArray {
|
||||||
|
val clean = hex.trim().removePrefix("0x")
|
||||||
|
require(clean.length % 2 == 0 && clean.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) {
|
||||||
|
"invalid hex"
|
||||||
|
}
|
||||||
|
return ByteArray(clean.length / 2) {
|
||||||
|
((Character.digit(clean[it * 2], 16) shl 4) + Character.digit(clean[it * 2 + 1], 16)).toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
48
app/src/main/java/com/rada/nostr/Nip04.kt
Normal file
48
app/src/main/java/com/rada/nostr/Nip04.kt
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package com.rada.nostr
|
||||||
|
|
||||||
|
import fr.acinq.secp256k1.Secp256k1
|
||||||
|
import java.util.Base64 as JBase64
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-04: ECDH shared secret + AES-256-CBC for encrypted DMs and the NWC
|
||||||
|
* (NIP-47) JSON-RPC envelope. Uses the app-held key via [keys].
|
||||||
|
*/
|
||||||
|
object Nip04 {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-04 shared secret = raw x-coordinate of priv*otherPub, NOT hashed.
|
||||||
|
* (ACINQ's ecdh() applies libsecp256k1's default sha256 hashfp, which is
|
||||||
|
* *not* what Nostr wants — pubKeyTweakMul gives the serialized point.)
|
||||||
|
*/
|
||||||
|
private fun ecdh(priv: ByteArray, pubkeyHex: String): ByteArray {
|
||||||
|
val compressed = byteArrayOf(0x02) + hexToBytes(pubkeyHex)
|
||||||
|
val point = Secp256k1.get().pubKeyTweakMul(compressed, priv)
|
||||||
|
return point.copyOfRange(1, 33) // x-coordinate, parity-independent
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encrypt(priv: ByteArray, recipientPubkeyHex: String, plaintext: String): String {
|
||||||
|
val shared = ecdh(priv, recipientPubkeyHex)
|
||||||
|
val iv = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||||
|
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(shared, "AES"), IvParameterSpec(iv))
|
||||||
|
val ct = cipher.doFinal(plaintext.toByteArray())
|
||||||
|
return JBase64.getEncoder().encodeToString(ct) + "?iv=" +
|
||||||
|
JBase64.getEncoder().encodeToString(iv)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decrypt(priv: ByteArray, senderPubkeyHex: String, payload: String): String? = runCatching {
|
||||||
|
val (ctB64, ivB64) = payload.split("?iv=", limit = 2)
|
||||||
|
val shared = ecdh(priv, senderPubkeyHex)
|
||||||
|
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||||
|
cipher.init(
|
||||||
|
Cipher.DECRYPT_MODE,
|
||||||
|
SecretKeySpec(shared, "AES"),
|
||||||
|
IvParameterSpec(JBase64.getDecoder().decode(ivB64)),
|
||||||
|
)
|
||||||
|
String(cipher.doFinal(JBase64.getDecoder().decode(ctB64)))
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
143
app/src/main/java/com/rada/nostr/NostrEvent.kt
Normal file
143
app/src/main/java/com/rada/nostr/NostrEvent.kt
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
package com.rada.nostr
|
||||||
|
|
||||||
|
import fr.acinq.secp256k1.Secp256k1
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-01 event. Minimal, immutable, serialization exactly per spec so the
|
||||||
|
* event id (SHA-256 of [serialized]) verifies on any relay/client.
|
||||||
|
*/
|
||||||
|
data class NostrEvent(
|
||||||
|
val id: String,
|
||||||
|
val pubkey: String,
|
||||||
|
val createdAt: Long,
|
||||||
|
val kind: Int,
|
||||||
|
val tags: List<List<String>>,
|
||||||
|
val content: String,
|
||||||
|
val sig: String,
|
||||||
|
) {
|
||||||
|
/** Canonical NIP-01 serialization: [0,pubkey,created_at,kind,tags,content]. */
|
||||||
|
fun serialized(): String {
|
||||||
|
val obj = buildJsonArray {
|
||||||
|
add(JsonPrimitive(0))
|
||||||
|
add(JsonPrimitive(pubkey))
|
||||||
|
add(JsonPrimitive(createdAt))
|
||||||
|
add(JsonPrimitive(kind))
|
||||||
|
add(buildJsonArray { tags.forEach { t -> add(buildJsonArray { t.forEach { add(JsonPrimitive(it)) } }) } })
|
||||||
|
add(JsonPrimitive(content))
|
||||||
|
}
|
||||||
|
return CANONICAL.encodeToString(JsonElement.serializer(), obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toJson(): String {
|
||||||
|
val obj = buildJsonObject {
|
||||||
|
put("id", id)
|
||||||
|
put("pubkey", pubkey)
|
||||||
|
put("created_at", createdAt)
|
||||||
|
put("kind", kind)
|
||||||
|
put("tags", buildJsonArray { tags.forEach { t -> add(buildJsonArray { t.forEach { add(JsonPrimitive(it)) } }) } })
|
||||||
|
put("content", content)
|
||||||
|
put("sig", sig)
|
||||||
|
}
|
||||||
|
return CANONICAL.encodeToString(JsonElement.serializer(), obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tag(name: String): String? = tags.firstOrNull { it.firstOrNull() == name }?.getOrNull(1)
|
||||||
|
fun tagValues(name: String): List<String> = tags.filter { it.firstOrNull() == name }.mapNotNull { it.getOrNull(1) }
|
||||||
|
|
||||||
|
/** Recompute id and verify schnorr sig. Never trust unverified relay data. */
|
||||||
|
fun verify(): Boolean = runCatching {
|
||||||
|
val expectedId = sha256Hex(serialized().toByteArray())
|
||||||
|
if (expectedId != id) return false
|
||||||
|
Secp256k1.get().verifySchnorr(
|
||||||
|
hexToBytes(sig),
|
||||||
|
hexToBytes(id),
|
||||||
|
hexToBytes(pubkey),
|
||||||
|
)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
internal val CANONICAL = Json { prettyPrint = false; encodeDefaults = true }
|
||||||
|
|
||||||
|
fun sha256Hex(bytes: ByteArray): String =
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(bytes).toHex()
|
||||||
|
|
||||||
|
fun unsigned(
|
||||||
|
pubkey: String,
|
||||||
|
kind: Int,
|
||||||
|
content: String,
|
||||||
|
tags: List<List<String>>,
|
||||||
|
createdAt: Long = System.currentTimeMillis() / 1000,
|
||||||
|
): UnsignedEvent = UnsignedEvent(pubkey, createdAt, kind, tags, content)
|
||||||
|
|
||||||
|
fun fromJson(raw: String): NostrEvent? = runCatching {
|
||||||
|
val el = CANONICAL.parseToJsonElement(raw)
|
||||||
|
val o = el as? kotlinx.serialization.json.JsonObject ?: return null
|
||||||
|
NostrEvent(
|
||||||
|
id = o["id"]?.jsonPrimitive?.content ?: return null,
|
||||||
|
pubkey = o["pubkey"]?.jsonPrimitive?.content ?: return null,
|
||||||
|
createdAt = o["created_at"]?.jsonPrimitive?.content?.toLongOrNull() ?: return null,
|
||||||
|
kind = o["kind"]?.jsonPrimitive?.content?.toIntOrNull() ?: return null,
|
||||||
|
tags = (o["tags"] as? JsonArray)?.map { a ->
|
||||||
|
a.jsonArray.map { it.jsonPrimitive.content }
|
||||||
|
} ?: emptyList(),
|
||||||
|
content = (o["content"] as? JsonPrimitive)?.content ?: "",
|
||||||
|
sig = o["sig"]?.jsonPrimitive?.content ?: return null,
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UnsignedEvent(
|
||||||
|
val pubkey: String,
|
||||||
|
val createdAt: Long,
|
||||||
|
val kind: Int,
|
||||||
|
val tags: List<List<String>>,
|
||||||
|
val content: String,
|
||||||
|
) {
|
||||||
|
fun serialized(): String {
|
||||||
|
val obj = buildJsonArray {
|
||||||
|
add(JsonPrimitive(0))
|
||||||
|
add(JsonPrimitive(pubkey))
|
||||||
|
add(JsonPrimitive(createdAt))
|
||||||
|
add(JsonPrimitive(kind))
|
||||||
|
add(buildJsonArray { tags.forEach { t -> add(buildJsonArray { t.forEach { add(JsonPrimitive(it)) } }) } })
|
||||||
|
add(JsonPrimitive(content))
|
||||||
|
}
|
||||||
|
return NostrEvent.CANONICAL.encodeToString(JsonElement.serializer(), obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign with the app-held key (signer callback keeps key ownership in NostrKeys). */
|
||||||
|
fun sign(signer: (ByteArray) -> String?): NostrEvent? {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256").digest(serialized().toByteArray())
|
||||||
|
val sig = signer(digest) ?: return null
|
||||||
|
val id = digest.toHex()
|
||||||
|
return NostrEvent(id, pubkey, createdAt, kind, tags, content, sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Music kinds from the Wavlake music NIPs (parameterized replaceable, NIP-33). */
|
||||||
|
object MusicKinds {
|
||||||
|
const val TRACK = 30440
|
||||||
|
const val ALBUM = 30441
|
||||||
|
const val ARTIST = 30442
|
||||||
|
const val PLAYLIST = 30443
|
||||||
|
|
||||||
|
const val METADATA = 0
|
||||||
|
const val TEXT_NOTE = 1
|
||||||
|
const val FOLLOW_LIST = 3
|
||||||
|
const val DELETION = 5
|
||||||
|
const val REACTION = 7
|
||||||
|
const val ZAP_REQUEST = 9734
|
||||||
|
const val ZAP_RECEIPT = 9735
|
||||||
|
}
|
||||||
223
app/src/main/java/com/rada/nostr/NostrKeys.kt
Normal file
223
app/src/main/java/com/rada/nostr/NostrKeys.kt
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
package com.rada.nostr
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import fr.acinq.secp256k1.Secp256k1
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/** Well-known Amber package, used only for store fallback text. NIP-55 login itself is signer-agnostic. */
|
||||||
|
const val AMBER_PACKAGE = "com.greenart7c3.nostrsigner"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional Nostr identity for the device (NIP-01 keys).
|
||||||
|
*
|
||||||
|
* Security posture:
|
||||||
|
* - Private key is encrypted with an AndroidKeyStore AES-GCM key that is
|
||||||
|
* non-exportable; only the ciphertext blob ever leaves the Keystore.
|
||||||
|
* - The plaintext key exists in memory only while signing an event.
|
||||||
|
* - Nostr is entirely optional: without an identity every other app
|
||||||
|
* feature works unchanged, and nothing here ever blocks playback.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class NostrKeys @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val alias = "satsamp_nostr_key"
|
||||||
|
private val prefs = context.getSharedPreferences("nostr_identity", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private val _pubkey = MutableStateFlow<String?>(null)
|
||||||
|
/** Hex x-only pubkey, or null when the user hasn't created/imported an identity. */
|
||||||
|
val pubkey: StateFlow<String?> = _pubkey.asStateFlow()
|
||||||
|
|
||||||
|
/** True when the user has a Keystore identity (encrypted private key) or a manually imported pubkey. */
|
||||||
|
val hasIdentity: Boolean get() = prefs.contains("ct") || prefs.contains("imported_pubkey")
|
||||||
|
|
||||||
|
/** True when the user has an identity of any kind (local or imported pubkey). */
|
||||||
|
val hasPubkey: Boolean get() = _pubkey.value != null
|
||||||
|
|
||||||
|
/** True only when the private key lives on this device (Keystore) and local
|
||||||
|
* signing works. An imported or external-signer (Amber) pubkey alone does
|
||||||
|
* NOT allow local signing — callers must fall back (e.g. ephemeral keys). */
|
||||||
|
val canSignLocally: Boolean get() = prefs.contains("ct")
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (hasIdentity) {
|
||||||
|
// If a Keystore identity exists, derive pubkey from it.
|
||||||
|
if (prefs.contains("ct")) {
|
||||||
|
runCatching {
|
||||||
|
val priv = decryptSecret()
|
||||||
|
if (priv != null) _pubkey.value = xOnlyPubkey(priv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If a manually imported pubkey exists, use it.
|
||||||
|
val imported = prefs.getString("imported_pubkey", null)
|
||||||
|
if (imported != null && _pubkey.value == null) {
|
||||||
|
_pubkey.value = imported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Package name of the external signer (NIP-55), if the user logged in that way. */
|
||||||
|
val signerPackage: String?
|
||||||
|
get() = prefs.getString("signer_package", null)
|
||||||
|
|
||||||
|
/** True when any NIP-55 external signer (Amber, etc.) is installed. Requires the nostrsigner <queries> entry. */
|
||||||
|
fun isExternalSignerInstalled(): Boolean = runCatching {
|
||||||
|
val probe = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:"))
|
||||||
|
if (context.packageManager.queryIntentActivities(probe, 0).isNotEmpty()) return true
|
||||||
|
// Fallback: direct Amber package check (needs the <package> queries entry).
|
||||||
|
context.packageManager.getPackageInfo(AMBER_PACKAGE, 0)
|
||||||
|
true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
/** Build the NIP-55 `get_public_key` intent. Launch via rememberLauncherForActivityResult. */
|
||||||
|
fun buildGetPubkeyIntent(): Intent =
|
||||||
|
Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:")).apply {
|
||||||
|
putExtra("type", "get_public_key")
|
||||||
|
putExtra(
|
||||||
|
"permissions",
|
||||||
|
"""[{"type":"sign_event"},{"type":"nip44_encrypt"},{"type":"nip44_decrypt"}]""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import a raw 64-char hex pubkey (NIP-55 results are hex, not npub). */
|
||||||
|
fun importHexPubkey(hex: String): Boolean {
|
||||||
|
val clean = hex.trim().lowercase()
|
||||||
|
if (!clean.matches(Regex("[0-9a-f]{64}"))) return false
|
||||||
|
_pubkey.value = clean
|
||||||
|
prefs.edit().putString("imported_pubkey", clean).apply()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the signer package from a `get_public_key` result for future sign_event calls. */
|
||||||
|
fun saveSignerPackage(pkg: String) {
|
||||||
|
prefs.edit().putString("signer_package", pkg).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a fresh identity. Returns the npub. Refuses if one exists (no silent overwrite). */
|
||||||
|
fun createIdentity(): String {
|
||||||
|
check(!hasIdentity) { "identity already exists" }
|
||||||
|
val rnd = java.security.SecureRandom()
|
||||||
|
var key = ByteArray(32)
|
||||||
|
do { rnd.nextBytes(key) } while (!Secp256k1.get().secKeyVerify(key))
|
||||||
|
val pub = xOnlyPubkey(key)
|
||||||
|
encryptAndStore(key)
|
||||||
|
_pubkey.value = pub
|
||||||
|
return Nip19.npub(pub)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import an nsec (paste flow). Stored encrypted; the plaintext is wiped after use. */
|
||||||
|
fun importNsec(nsec: String): Boolean {
|
||||||
|
val (hrp, bytes) = Nip19.decode(nsec.trim()) ?: return false
|
||||||
|
if (hrp != "nsec" || bytes.size != 32) return false
|
||||||
|
if (!Secp256k1.get().secKeyVerify(bytes)) return false
|
||||||
|
encryptAndStore(bytes)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import just a pubkey (manual paste fallback).
|
||||||
|
* Allows relay connectivity for profile lookups without a Keystore identity. */
|
||||||
|
fun importPubkey(npub: String): Boolean {
|
||||||
|
val hex = runCatching { Nip19.decode(npub.trim()) }.getOrNull()?.let { (hrp, bytes) ->
|
||||||
|
if (hrp == "npub" && bytes.size == 32) bytes.joinToString("") { "%02x".format(it) } else null
|
||||||
|
} ?: return false
|
||||||
|
_pubkey.value = hex
|
||||||
|
prefs.edit().putString("imported_pubkey", hex).apply()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun npub(): String? = _pubkey.value?.let { Nip19.npub(it) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign `digest` (32-byte SHA-256 of serialized event) with the stored key.
|
||||||
|
* Returns 64-byte BIP-340 schnorr sig hex, or null when no identity.
|
||||||
|
*/
|
||||||
|
fun signDigest(digest: ByteArray): String? {
|
||||||
|
val priv = decryptSecret() ?: return null
|
||||||
|
try {
|
||||||
|
return Secp256k1.get().signSchnorr(digest, priv, null).toHex()
|
||||||
|
} finally {
|
||||||
|
priv.fill(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NIP-47/NIP-44 style ECDH needs the full priv transiently too. */
|
||||||
|
fun <T> withPrivateKey(block: (ByteArray) -> T): T? {
|
||||||
|
val priv = decryptSecret() ?: return null
|
||||||
|
try { return block(priv) } finally { priv.fill(0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erase the identity (ciphertext + keystore key). */
|
||||||
|
fun clearIdentity() {
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
runCatching {
|
||||||
|
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
.deleteEntry(alias)
|
||||||
|
}
|
||||||
|
_pubkey.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keystore plumbing ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun keystoreKey(): SecretKey {
|
||||||
|
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
(ks.getKey(alias, null) as? SecretKey)?.let { return it }
|
||||||
|
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||||
|
gen.init(
|
||||||
|
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setKeySize(256)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
return gen.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encryptAndStore(priv: ByteArray) {
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, keystoreKey())
|
||||||
|
val ct = cipher.doFinal(priv)
|
||||||
|
prefs.edit()
|
||||||
|
.putString("ct", Base64.encodeToString(ct, Base64.NO_WRAP))
|
||||||
|
.putString("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
|
||||||
|
.apply()
|
||||||
|
priv.fill(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decryptSecret(): ByteArray? {
|
||||||
|
val ct = prefs.getString("ct", null) ?: return null
|
||||||
|
val iv = prefs.getString("iv", null) ?: return null
|
||||||
|
return runCatching {
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(
|
||||||
|
Cipher.DECRYPT_MODE,
|
||||||
|
keystoreKey(),
|
||||||
|
GCMParameterSpec(128, Base64.decode(iv, Base64.NO_WRAP)),
|
||||||
|
)
|
||||||
|
cipher.doFinal(Base64.decode(ct, Base64.NO_WRAP))
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** compressed pubkey (33B) minus the 02/03 prefix = x-only (32B), NIP-01 style. */
|
||||||
|
fun xOnlyPubkey(priv: ByteArray): String {
|
||||||
|
val compressed = Secp256k1.get().pubkeyCreate(priv)
|
||||||
|
return compressed.copyOfRange(1, 33).toHex()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
233
app/src/main/java/com/rada/nostr/NostrService.kt
Normal file
233
app/src/main/java/com/rada/nostr/NostrService.kt
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
package com.rada.nostr
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
data class RelayInfo(val url: String, val connected: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single Nostr entry point (publish/subscribe/query/profile/music).
|
||||||
|
* UI must never talk to RelayConnection directly.
|
||||||
|
*
|
||||||
|
* Failure posture: relays are best-effort. If every relay is down, flows
|
||||||
|
* simply never emit; nothing else in the app notices or degrades.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class NostrService @Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
private val keys: NostrKeys,
|
||||||
|
) {
|
||||||
|
private val prefs = context.getSharedPreferences("nostr_settings", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.pingInterval(30, TimeUnit.SECONDS)
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS) // websockets: no read timeout
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
private val defaultRelays = listOf(
|
||||||
|
"wss://relay.wavlake.com",
|
||||||
|
"wss://relay.damus.io",
|
||||||
|
"wss://relay.primal.net",
|
||||||
|
"wss://nos.lol",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val relayUrls: List<String>
|
||||||
|
get() {
|
||||||
|
val saved = prefs.getString("relays", null)
|
||||||
|
?.split("\n")?.filter { it.isNotBlank() }
|
||||||
|
val base = if (saved.isNullOrEmpty()) defaultRelays else saved
|
||||||
|
// Wavlake's own relay always rides along (first): artist kind-0s
|
||||||
|
// live there even when users customize the rest. Deduped.
|
||||||
|
return (listOf("wss://relay.wavlake.com") + base).distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val relays = java.util.concurrent.ConcurrentHashMap<String, RelayConnection>()
|
||||||
|
|
||||||
|
private val _relayStates = MutableStateFlow<List<RelayInfo>>(emptyList())
|
||||||
|
val relayStates: StateFlow<List<RelayInfo>> = _relayStates.asStateFlow()
|
||||||
|
|
||||||
|
/** Merged verified events from all relays (deduped by id downstream). */
|
||||||
|
private val merged = kotlinx.coroutines.flow.MutableSharedFlow<NostrEvent>(
|
||||||
|
extraBufferCapacity = 512,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Volatile private var started = false
|
||||||
|
|
||||||
|
/** Call once when the user enables Nostr; safe to call repeatedly. */
|
||||||
|
fun ensureConnected() {
|
||||||
|
if (started) return
|
||||||
|
synchronized(this) {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
relayUrls.forEach { url ->
|
||||||
|
val conn = RelayConnection(url, client)
|
||||||
|
relays[url] = conn
|
||||||
|
scope.launch {
|
||||||
|
conn.state.collect { st ->
|
||||||
|
_relayStates.value = relays.map { (u, c) ->
|
||||||
|
RelayInfo(u, st is RelayConnection.State.Connected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch { conn.events.collect { merged.emit(it) } }
|
||||||
|
conn.connect()
|
||||||
|
}
|
||||||
|
_relayStates.value = relays.map { RelayInfo(it.key, false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val events get() = merged as kotlinx.coroutines.flow.SharedFlow<NostrEvent>
|
||||||
|
|
||||||
|
val enabled: Boolean
|
||||||
|
get() = prefs.getBoolean("enabled", false) && keys.hasIdentity
|
||||||
|
|
||||||
|
fun setEnabled(on: Boolean) {
|
||||||
|
prefs.edit().putBoolean("enabled", on).apply()
|
||||||
|
if (on) ensureConnected()
|
||||||
|
else {
|
||||||
|
relays.values.forEach { it.close() }
|
||||||
|
started = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun relaysConfig(): List<String> = relayUrls
|
||||||
|
fun setRelays(list: List<String>) {
|
||||||
|
prefs.edit().putString("relays", list.filter { it.isNotBlank() }.joinToString("\n")).apply()
|
||||||
|
relays.values.forEach { it.close() }
|
||||||
|
relays.clear()
|
||||||
|
started = false
|
||||||
|
if (enabled) ensureConnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── publish ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Publish a pre-signed event (zap requests use ephemeral keys). */
|
||||||
|
fun publishRaw(event: NostrEvent) {
|
||||||
|
ensureConnected()
|
||||||
|
relays.values.forEach { it.publish(event) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Await the lnurl server's zap receipt (kind 9735) referencing [requestId]. */
|
||||||
|
suspend fun waitForZapReceipt(requestId: String, targetPubkey: String, timeoutMs: Long) {
|
||||||
|
ensureConnected()
|
||||||
|
runCatching {
|
||||||
|
kotlinx.coroutines.withTimeout(timeoutMs) {
|
||||||
|
events.first {
|
||||||
|
it.kind == MusicKinds.ZAP_RECEIPT &&
|
||||||
|
it.tagValues("e").contains(requestId) &&
|
||||||
|
it.pubkey == targetPubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publish(kind: Int, content: String, tags: List<List<String>>): NostrEvent? {
|
||||||
|
val ev = NostrEvent.unsigned(
|
||||||
|
pubkey = keys.pubkey.value ?: return null,
|
||||||
|
kind = kind, content = content, tags = tags,
|
||||||
|
).sign { digest -> keys.signDigest(digest) } ?: return null
|
||||||
|
relays.values.forEach { it.publish(ev) }
|
||||||
|
return ev
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kind 0 metadata (NIP-01 profile). */
|
||||||
|
fun publishProfile(name: String, about: String, pictureUrl: String): NostrEvent? {
|
||||||
|
val content = JSONObject().apply {
|
||||||
|
put("name", name)
|
||||||
|
put("about", about)
|
||||||
|
if (pictureUrl.isNotBlank()) put("picture", pictureUrl)
|
||||||
|
}.toString()
|
||||||
|
return publish(MusicKinds.METADATA, content, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kind 7 reaction ("+") to an event, optional lightning zap request. */
|
||||||
|
fun publishReaction(target: NostrEvent, content: String = "+"): NostrEvent? =
|
||||||
|
publish(
|
||||||
|
MusicKinds.REACTION, content,
|
||||||
|
listOf(
|
||||||
|
listOf("e", target.id),
|
||||||
|
listOf("p", target.pubkey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── music metadata (Wavlake music NIPs 30440-30443) ──────────────────
|
||||||
|
|
||||||
|
/** Publish a track as kind 30440 with a NOM-flavored JSON payload. */
|
||||||
|
fun publishTrack(
|
||||||
|
dTag: String,
|
||||||
|
title: String,
|
||||||
|
creator: String,
|
||||||
|
enclosureUrl: String,
|
||||||
|
mimeType: String = "audio/mpeg",
|
||||||
|
durationSec: Long = 0,
|
||||||
|
): NostrEvent? {
|
||||||
|
val content = JSONObject().apply {
|
||||||
|
put("title", title)
|
||||||
|
put("guid", dTag)
|
||||||
|
put("creator", creator)
|
||||||
|
put("type", mimeType)
|
||||||
|
put("duration", durationSec)
|
||||||
|
put("published_at", (System.currentTimeMillis() / 1000).toString())
|
||||||
|
put("enclosure", enclosureUrl)
|
||||||
|
put("version", "0.1")
|
||||||
|
}.toString()
|
||||||
|
return publish(MusicKinds.TRACK, content, listOf(listOf("d", dTag)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── query (one-shot-ish) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Subscribe with [filter]; collected events (verified) flow into [onEvent]. */
|
||||||
|
fun query(filter: JSONObject, onEvent: (NostrEvent) -> Unit): String {
|
||||||
|
ensureConnected()
|
||||||
|
val ids = relays.values.map { it.subscribe(filter) }
|
||||||
|
scope.launch {
|
||||||
|
val seen = HashSet<String>()
|
||||||
|
events.collect { ev ->
|
||||||
|
if (seen.add(ev.id)) onEvent(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids.firstOrNull() ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch a profile (kind 0) for [pubkeyHex]; emits once if any relay has it. */
|
||||||
|
fun getProfile(pubkeyHex: String, timeoutMs: Long = 6000): kotlinx.coroutines.flow.Flow<NostrEvent?> {
|
||||||
|
val out = kotlinx.coroutines.flow.MutableStateFlow<NostrEvent?>(null)
|
||||||
|
ensureConnected()
|
||||||
|
val filter = JSONObject().apply {
|
||||||
|
put("kinds", JSONArray().put(MusicKinds.METADATA))
|
||||||
|
put("authors", JSONArray().put(pubkeyHex))
|
||||||
|
put("limit", 1)
|
||||||
|
}
|
||||||
|
// The merged flow only carries what subscriptions request — without
|
||||||
|
// this REQ the lookup below could only catch someone else's traffic.
|
||||||
|
val subs = relays.values.map { conn -> conn to conn.subscribe(filter) }
|
||||||
|
val job = scope.launch {
|
||||||
|
events.first { it.kind == MusicKinds.METADATA && it.pubkey == pubkeyHex }
|
||||||
|
.let { out.value = it }
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
kotlinx.coroutines.delay(timeoutMs)
|
||||||
|
job.cancel()
|
||||||
|
subs.forEach { (conn, id) -> runCatching { conn.unsubscribe(id) } }
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
148
app/src/main/java/com/rada/nostr/RelayConnection.kt
Normal file
148
app/src/main/java/com/rada/nostr/RelayConnection.kt
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
package com.rada.nostr
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal NIP-01 relay client over OkHttp WebSocket — no new dependencies.
|
||||||
|
* Design guarantees for the app:
|
||||||
|
* - Every method is safe to call with relays down: failures surface as
|
||||||
|
* closed flows / nulls, never exceptions into the UI, never blocking
|
||||||
|
* playback.
|
||||||
|
* - Events arriving from relays are [NostrEvent.verify]-checked before
|
||||||
|
* they are emitted; untrusted relay data can't inject fakes.
|
||||||
|
*/
|
||||||
|
class RelayConnection(
|
||||||
|
val url: String,
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
) {
|
||||||
|
sealed interface State {
|
||||||
|
data object Connecting : State
|
||||||
|
data object Connected : State
|
||||||
|
data class Disconnected(val reason: String?) : State
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _state = MutableSharedFlow<State>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||||
|
val state: SharedFlow<State> = _state.asSharedFlow()
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<NostrEvent>(
|
||||||
|
replay = 0, extraBufferCapacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
/** Verified events matching any subscription on this relay. */
|
||||||
|
val events: SharedFlow<NostrEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
/** reqId -> original filter JSON, so we can CLOSE. */
|
||||||
|
private val subs = ConcurrentHashMap<String, JSONArray>()
|
||||||
|
private val reqCounter = AtomicLong(0)
|
||||||
|
@Volatile private var socket: WebSocket? = null
|
||||||
|
private val pendingOut = ArrayDeque<String>()
|
||||||
|
|
||||||
|
val isConnected: Boolean get() = socket != null
|
||||||
|
|
||||||
|
fun connect() {
|
||||||
|
if (socket != null) return
|
||||||
|
_state.tryEmit(State.Connecting)
|
||||||
|
runCatching {
|
||||||
|
socket = client.newWebSocket(
|
||||||
|
Request.Builder().url(url).build(),
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
Log.d(TAG, "connected: $url")
|
||||||
|
_state.tryEmit(State.Connected)
|
||||||
|
// Re-send subscriptions and queued events after (re)connect.
|
||||||
|
subs.values.forEach { w -> runCatching { webSocket.send(w.toString()) } }
|
||||||
|
while (pendingOut.isNotEmpty()) {
|
||||||
|
webSocket.send(pendingOut.removeFirst())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) = handleFrame(text)
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
Log.w(TAG, "connection failed: $url: ${t.message}")
|
||||||
|
socket = null
|
||||||
|
_state.tryEmit(State.Disconnected(t.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
Log.d(TAG, "closed: $url ($code)")
|
||||||
|
socket = null
|
||||||
|
_state.tryEmit(State.Disconnected(reason))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}.onFailure {
|
||||||
|
_state.tryEmit(State.Disconnected(it.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleFrame(text: String) {
|
||||||
|
runCatching {
|
||||||
|
val arr = JSONArray(text)
|
||||||
|
when (arr.optString(0)) {
|
||||||
|
"EVENT" -> {
|
||||||
|
val ev = NostrEvent.fromJson(arr.optString(2))
|
||||||
|
// Trust boundary: id + schnorr signature must verify.
|
||||||
|
if (ev != null && ev.verify()) _events.tryEmit(ev)
|
||||||
|
}
|
||||||
|
// ["OK", evt_id, ok|fail, msg] and ["EOSE", sub_id] are handled
|
||||||
|
// by callers that need them via their own flow when extended;
|
||||||
|
// for now presence is enough.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NIP-01 REQ. Returns subscription id; results arrive on [events]. */
|
||||||
|
fun subscribe(filter: JSONObject): String {
|
||||||
|
val id = "s${reqCounter.incrementAndGet()}"
|
||||||
|
// NIP-01: ["REQ", sub_id, filter]
|
||||||
|
subs[id] = JSONArray().put("REQ").put(id).put(filter)
|
||||||
|
val ws = socket
|
||||||
|
// send() on a non-open socket fails silently — the pending REQ is
|
||||||
|
// re-sent from onOpen, so delivery is still guaranteed.
|
||||||
|
val sent = if (ws != null) runCatching { ws.send(subs[id].toString()) }.getOrDefault(false) else false
|
||||||
|
Log.d(TAG, "REQ $id -> $url (immediate=$sent)")
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unsubscribe(id: String) {
|
||||||
|
subs.remove(id)
|
||||||
|
// NIP-01 CLOSE is an array, not an object.
|
||||||
|
val close = JSONArray().put("CLOSE").put(id).toString()
|
||||||
|
runCatching { socket?.send(close) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publish(event: NostrEvent) {
|
||||||
|
val msg = JSONArray().apply {
|
||||||
|
put("EVENT")
|
||||||
|
put(JSONObject(event.toJson()))
|
||||||
|
}.toString()
|
||||||
|
val ws = socket
|
||||||
|
if (ws != null && ws.send(msg)) return
|
||||||
|
// Queue for the next successful connect; bounded.
|
||||||
|
if (pendingOut.size < 32) pendingOut.addLast(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
subs.clear()
|
||||||
|
runCatching { socket?.close(1000, null) }
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "Relay"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/src/main/java/com/rada/payments/LightningAddress.kt
Normal file
27
app/src/main/java/com/rada/payments/LightningAddress.kt
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
package com.rada.payments
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict Lightning Address validation. A zap affordance may ONLY be driven
|
||||||
|
* by [hasValidLightningAddress]; never by Wavlake membership, Nostr keys,
|
||||||
|
* LNURL, lud06/lud16, payment URLs, or any other Lightning metadata.
|
||||||
|
*/
|
||||||
|
fun hasValidLightningAddress(lightningAddress: String?): Boolean {
|
||||||
|
if (lightningAddress == null) return false
|
||||||
|
val value = lightningAddress.trim()
|
||||||
|
if (value.isEmpty()) return false
|
||||||
|
val parts = value.split("@")
|
||||||
|
if (parts.size != 2) return false
|
||||||
|
val username = parts[0].trim()
|
||||||
|
val domain = parts[1].trim()
|
||||||
|
if (username.isEmpty() || domain.isEmpty()) return false
|
||||||
|
if (domain.contains(" ")) return false
|
||||||
|
if (!domain.contains(".")) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalized form of a valid address, or null. */
|
||||||
|
fun normalizedLightningAddress(lightningAddress: String?): String? {
|
||||||
|
if (!hasValidLightningAddress(lightningAddress)) return null
|
||||||
|
val parts = lightningAddress!!.trim().split("@")
|
||||||
|
return "${parts[0].trim()}@${parts[1].trim()}"
|
||||||
|
}
|
||||||
286
app/src/main/java/com/rada/payments/NwcProvider.kt
Normal file
286
app/src/main/java/com/rada/payments/NwcProvider.kt
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
package com.rada.payments
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import com.rada.nostr.Nip04
|
||||||
|
import com.rada.nostr.NostrEvent
|
||||||
|
import com.rada.nostr.NostrKeys
|
||||||
|
import com.rada.nostr.NostrService
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.util.Base64 as JBase64
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nostr Wallet Connect (NIP-47) provider: the app talks to the user's
|
||||||
|
* wallet *through their relay* using encrypted kind-13194 info events and
|
||||||
|
* kind-13195/23194/23195 request/response DMs. The app never holds wallet
|
||||||
|
* keys; it holds only the pairing connection string (encrypted at rest).
|
||||||
|
*
|
||||||
|
* Connection string format (from Alby Hub / coinos etc.):
|
||||||
|
* nostr+walletconnect://<walletPubkey>?relay=<wss://..>&secret=<hex>
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class NwcProvider @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val nostr: NostrService,
|
||||||
|
private val keys: NostrKeys,
|
||||||
|
) : PaymentProvider {
|
||||||
|
|
||||||
|
override val id = "nwc"
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences("nwc", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private var walletPubkey: String? = null
|
||||||
|
private var relayUrl: String? = null
|
||||||
|
private var secretHex: String? = null
|
||||||
|
|
||||||
|
val connected: Boolean get() = parseStored() != null
|
||||||
|
|
||||||
|
override val isConfigured: Boolean get() = connected
|
||||||
|
|
||||||
|
data class Pairing(val walletPubkey: String, val relayUrl: String, val secretHex: String)
|
||||||
|
|
||||||
|
/** Parse + validate a pairing string. Stores it; returns false if malformed. */
|
||||||
|
fun connect(pairingUri: String): Boolean {
|
||||||
|
val parsed = parse(pairingUri.trim()) ?: return false
|
||||||
|
// store encrypted (it contains a secret key!)
|
||||||
|
storeSecret(pairingUri.trim())
|
||||||
|
walletPubkey = parsed.walletPubkey
|
||||||
|
relayUrl = parsed.relayUrl
|
||||||
|
secretHex = parsed.secretHex
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
walletPubkey = null; relayUrl = null; secretHex = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parse(uri: String): Pairing? = runCatching {
|
||||||
|
require(uri.startsWith("nostr+walletconnect://")) { "not an NWC uri" }
|
||||||
|
val rest = uri.removePrefix("nostr+walletconnect://")
|
||||||
|
val pubkey = rest.substringBefore("?")
|
||||||
|
require(pubkey.length == 64 && pubkey.all { it in '0'..'9' || it in 'a'..'f' }) { "bad pubkey" }
|
||||||
|
val qs = rest.substringAfter("?", "")
|
||||||
|
val params = qs.split("&").mapNotNull {
|
||||||
|
val k = it.substringBefore("="); val v = it.substringAfter("=", "")
|
||||||
|
k to java.net.URLDecoder.decode(v, "UTF-8")
|
||||||
|
}.toMap()
|
||||||
|
val relay = params["relay"] ?: return null
|
||||||
|
val secret = params["secret"] ?: return null
|
||||||
|
require(secret.length == 64) { "bad secret" }
|
||||||
|
Pairing(pubkey, relay, secret)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun parseStored(): Pairing? {
|
||||||
|
if (walletPubkey != null) return Pairing(walletPubkey!!, relayUrl!!, secretHex!!)
|
||||||
|
val ct = prefs.getString("pairing_ct", null) ?: return null
|
||||||
|
val iv = prefs.getString("pairing_iv", null) ?: return null
|
||||||
|
val uri = decrypt(ct, iv) ?: return null
|
||||||
|
val p = parse(uri) ?: return null
|
||||||
|
walletPubkey = p.walletPubkey; relayUrl = p.relayUrl; secretHex = p.secretHex
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── provider API ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun payLightningAddress(
|
||||||
|
address: String,
|
||||||
|
sats: Long,
|
||||||
|
memo: String?,
|
||||||
|
): PaymentResult {
|
||||||
|
// NIP-47 pay_invoice needs a bolt11; a lightning *address* first
|
||||||
|
// resolves via LUD-06 to an invoice.
|
||||||
|
val identifier = if (address.contains("@")) {
|
||||||
|
resolveLightningAddress(address, sats, memo)
|
||||||
|
?: return PaymentResult.Failed("lnurl failed", "Couldn't reach that Lightning address.")
|
||||||
|
} else address
|
||||||
|
return nwcRequest("pay_invoice", JSONObject().apply {
|
||||||
|
put("invoice", identifier)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun payInvoice(bolt11: String, sats: Long): PaymentResult =
|
||||||
|
nwcRequest("pay_invoice", JSONObject().apply { put("invoice", bolt11) })
|
||||||
|
|
||||||
|
override suspend fun createInvoice(sats: Long, memo: String?): PaymentResult =
|
||||||
|
nwcRequest("make_invoice", JSONObject().apply {
|
||||||
|
put("amount", sats * 1000) // msats
|
||||||
|
memo?.let { put("description", it) }
|
||||||
|
})
|
||||||
|
|
||||||
|
/** NIP-06/LUD-16: lightning address -> {callback}?amount= -> bolt11 */
|
||||||
|
private suspend fun resolveLightningAddress(
|
||||||
|
address: String,
|
||||||
|
sats: Long,
|
||||||
|
memo: String?,
|
||||||
|
): String? = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val (name, domain) = address.split("@", limit = 2)
|
||||||
|
val https = "https://$domain/.well-known/lnurlp/$name"
|
||||||
|
val client = okhttp3.OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
val lnl = client.newCall(okhttp3.Request.Builder().url(https).build())
|
||||||
|
.execute().use { r ->
|
||||||
|
if (!r.isSuccessful) return@runCatching null
|
||||||
|
JSONObject(r.body?.string() ?: return@runCatching null)
|
||||||
|
}
|
||||||
|
if (lnl.optString("tag") != "payRequest") return@runCatching null
|
||||||
|
val min = lnl.optLong("minSendable", 1000L) // msats
|
||||||
|
val max = lnl.optLong("maxSendable", Long.MAX_VALUE)
|
||||||
|
val msats = (sats * 1000).coerceIn(min, max)
|
||||||
|
val cb = lnl.getString("callback")
|
||||||
|
val url = "$cb${if (cb.contains("?")) "&" else "?"}amount=$msats" +
|
||||||
|
(memo?.let { "&comment=${java.net.URLEncoder.encode(it, "UTF-8")}" } ?: "")
|
||||||
|
val resp = client.newCall(okhttp3.Request.Builder().url(url).build())
|
||||||
|
.execute().use { r -> JSONObject(r.body?.string() ?: return@runCatching null) }
|
||||||
|
if (resp.has("error")) {
|
||||||
|
return@runCatching null
|
||||||
|
}
|
||||||
|
resp.optString("pr").takeIf { it.isNotBlank() }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a NIP-47 request event, await the wallet's encrypted response.
|
||||||
|
* Timeout-safe: returns Failed, never hangs the caller.
|
||||||
|
*/
|
||||||
|
private suspend fun nwcRequest(method: String, params: JSONObject): PaymentResult {
|
||||||
|
val pairing = parseStored() ?: return PaymentResult.Failed(
|
||||||
|
"not connected", "Connect your wallet in Settings → Nostr.",
|
||||||
|
)
|
||||||
|
val ourPub = keys.pubkey.value ?: return PaymentResult.Failed(
|
||||||
|
"no identity", "Create a Nostr identity first (Settings → Nostr).",
|
||||||
|
)
|
||||||
|
|
||||||
|
val deferred = CompletableDeferred<JSONObject>()
|
||||||
|
// Subscribe to the wallet's response kind for our pubkey (NIP-47: 23195).
|
||||||
|
val filter = JSONObject().apply {
|
||||||
|
put("kinds", org.json.JSONArray().put(23195))
|
||||||
|
put("authors", org.json.JSONArray().put(pairing.walletPubkey))
|
||||||
|
put("#p", org.json.JSONArray().put(ourPub))
|
||||||
|
put("limit", 5)
|
||||||
|
}
|
||||||
|
val requestId = java.util.UUID.randomUUID().toString()
|
||||||
|
val payload = JSONObject().apply {
|
||||||
|
put("method", method)
|
||||||
|
put("params", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
val enc = keys.withPrivateKey { priv ->
|
||||||
|
Nip04.encrypt(priv, pairing.walletPubkey, payload.toString())
|
||||||
|
} ?: return PaymentResult.Failed("crypto", "Couldn't encrypt the request.")
|
||||||
|
|
||||||
|
// Publish request (kind 23194) to the NWC relay AND app relays.
|
||||||
|
val ev = NostrEvent.unsigned(
|
||||||
|
pubkey = ourPub, kind = 23194, content = enc,
|
||||||
|
tags = listOf(listOf("p", pairing.walletPubkey)),
|
||||||
|
).sign { d -> keys.signDigest(d) }
|
||||||
|
?: return PaymentResult.Failed("signing", "Couldn't sign the request.")
|
||||||
|
|
||||||
|
// A dedicated relay connection to the NWC relay from the pairing.
|
||||||
|
val nwcRelay = com.rada.nostr.RelayConnection(
|
||||||
|
pairing.relayUrl,
|
||||||
|
okhttp3.OkHttpClient.Builder()
|
||||||
|
.pingInterval(20, java.util.concurrent.TimeUnit.SECONDS).build(),
|
||||||
|
)
|
||||||
|
return try {
|
||||||
|
nwcRelay.connect()
|
||||||
|
// Listen for the response on the NWC relay only.
|
||||||
|
val listen = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
val listenJob = listen.launch {
|
||||||
|
nwcRelay.events.collect { e ->
|
||||||
|
if (e.kind == 23195 && !deferred.isCompleted) {
|
||||||
|
val plain = keys.withPrivateKey { priv ->
|
||||||
|
Nip04.decrypt(priv, pairing.walletPubkey, e.content)
|
||||||
|
} ?: return@collect
|
||||||
|
runCatching { JSONObject(plain) }
|
||||||
|
.onSuccess { deferred.complete(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nwcRelay.publish(ev)
|
||||||
|
val result = withTimeoutOrNull(15_000) { deferred.await() }
|
||||||
|
listenJob.cancel()
|
||||||
|
result?.let { json ->
|
||||||
|
when {
|
||||||
|
json.has("error") -> PaymentResult.Failed(
|
||||||
|
json.optString("error"),
|
||||||
|
"Wallet declined: ${json.optString("error_message", json.optString("error"))}",
|
||||||
|
)
|
||||||
|
json.has("result_type") -> {
|
||||||
|
val res = json.optJSONObject("result")
|
||||||
|
when (method) {
|
||||||
|
"make_invoice" -> PaymentResult.InvoiceCreated(
|
||||||
|
res?.optString("invoice") ?: "", pairing.walletPubkey,
|
||||||
|
)
|
||||||
|
"pay_invoice" -> PaymentResult.Paid
|
||||||
|
"get_info" -> PaymentResult.Paid
|
||||||
|
else -> PaymentResult.Paid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> PaymentResult.Failed("bad response", "Unexpected wallet response.")
|
||||||
|
}
|
||||||
|
} ?: PaymentResult.Failed("timeout", "Wallet didn't respond in time.")
|
||||||
|
} finally {
|
||||||
|
nwcRelay.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wallet info (NIP-47 get_info) — names the connected wallet. */
|
||||||
|
suspend fun getInfo(): String? {
|
||||||
|
val r = nwcRequest("get_info", JSONObject())
|
||||||
|
return if (r is PaymentResult.Paid) "connected" else null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── keystore at-rest protection for the pairing secret ───────────────
|
||||||
|
|
||||||
|
private fun ksKey(): SecretKey {
|
||||||
|
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
(ks.getKey("satsamp_nwc", null) as? SecretKey)?.let { return it }
|
||||||
|
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||||
|
gen.init(
|
||||||
|
KeyGenParameterSpec.Builder("satsamp_nwc",
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
return gen.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storeSecret(uri: String) {
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, ksKey())
|
||||||
|
prefs.edit()
|
||||||
|
.putString("pairing_ct", Base64.encodeToString(cipher.doFinal(uri.toByteArray()), Base64.NO_WRAP))
|
||||||
|
.putString("pairing_iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decrypt(ctB64: String, ivB64: String): String? = runCatching {
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(
|
||||||
|
Cipher.DECRYPT_MODE, ksKey(),
|
||||||
|
GCMParameterSpec(128, Base64.decode(ivB64, Base64.NO_WRAP)),
|
||||||
|
)
|
||||||
|
String(cipher.doFinal(Base64.decode(ctB64, Base64.NO_WRAP)))
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
112
app/src/main/java/com/rada/payments/PaymentService.kt
Normal file
112
app/src/main/java/com/rada/payments/PaymentService.kt
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package com.rada.payments
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment abstraction — the rest of the app only ever talks to
|
||||||
|
* [PaymentService]; provider specifics (NWC, LNURL, Lightning Address,
|
||||||
|
* Cashu later) stay behind [PaymentProvider].
|
||||||
|
*
|
||||||
|
* Guarantees the app relies on:
|
||||||
|
* - No payment failure ever propagates into playback code.
|
||||||
|
* - Nothing auto-spends: pay() is only invoked from explicit UI actions
|
||||||
|
* or bounded value-for-value logic with user-set caps.
|
||||||
|
*/
|
||||||
|
sealed interface PaymentResult {
|
||||||
|
data class InvoiceCreated(val bolt11: String, val lightningAddress: String?) : PaymentResult
|
||||||
|
data object Paid : PaymentResult
|
||||||
|
data class Failed(val reason: String, val userFacing: String) : PaymentResult
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentProvider {
|
||||||
|
val id: String
|
||||||
|
val isConfigured: Boolean
|
||||||
|
|
||||||
|
/** Pay [sats] to a lightning address / lnurl (LUD-06/LUD-07/LUD-16 flow). */
|
||||||
|
suspend fun payLightningAddress(address: String, sats: Long, memo: String?): PaymentResult
|
||||||
|
|
||||||
|
/** Create an invoice to ourselves (NWC make_invoice) — for receiving. */
|
||||||
|
suspend fun createInvoice(sats: Long, memo: String?): PaymentResult
|
||||||
|
|
||||||
|
/** Pay a bolt11 invoice directly (zap flow). */
|
||||||
|
suspend fun payInvoice(bolt11: String, sats: Long): PaymentResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central payment service. Providers are consulted in priority order;
|
||||||
|
* when none is configured every method fails fast with a clear reason
|
||||||
|
* and the UI renders the plain "⚡ Support" flow as connect-prompt.
|
||||||
|
*/
|
||||||
|
class PaymentService(
|
||||||
|
private val providers: () -> List<PaymentProvider>,
|
||||||
|
) {
|
||||||
|
private val _history = MutableStateFlow<List<PaymentRecord>>(emptyList())
|
||||||
|
val history: StateFlow<List<PaymentRecord>> = _history.asStateFlow()
|
||||||
|
|
||||||
|
val hasAnyWallet: Boolean get() = providers().any { it.isConfigured }
|
||||||
|
|
||||||
|
suspend fun tipArtist(
|
||||||
|
lightningAddress: String?,
|
||||||
|
sats: Long,
|
||||||
|
artistName: String,
|
||||||
|
trackTitle: String? = null,
|
||||||
|
): PaymentResult {
|
||||||
|
if (sats <= 0) return PaymentResult.Failed("invalid amount", "Enter an amount first.")
|
||||||
|
if (lightningAddress.isNullOrBlank()) {
|
||||||
|
return PaymentResult.Failed("no address", "$artistName has no Lightning address set.")
|
||||||
|
}
|
||||||
|
val provider = providers().firstOrNull { it.isConfigured }
|
||||||
|
?: return PaymentResult.Failed(
|
||||||
|
"no wallet",
|
||||||
|
"Connect a wallet first (Settings → Nostr → Bitcoin).",
|
||||||
|
)
|
||||||
|
val memo = listOfNotNull(trackTitle?.takeIf { it.isNotBlank() }, "via SatsAmp")
|
||||||
|
.joinToString(" · ")
|
||||||
|
val result = runCatching { provider.payLightningAddress(lightningAddress, sats, memo) }
|
||||||
|
.getOrElse {
|
||||||
|
PaymentResult.Failed("provider error: ${it.message}", "Payment failed — try again.")
|
||||||
|
}
|
||||||
|
_history.value = listOf(
|
||||||
|
PaymentRecord(
|
||||||
|
timestampMs = System.currentTimeMillis(),
|
||||||
|
artist = artistName,
|
||||||
|
track = trackTitle,
|
||||||
|
sats = sats,
|
||||||
|
method = provider.id,
|
||||||
|
success = result is PaymentResult.Paid || result is PaymentResult.InvoiceCreated,
|
||||||
|
detail = when (result) {
|
||||||
|
is PaymentResult.Failed -> result.reason
|
||||||
|
is PaymentResult.InvoiceCreated -> result.bolt11.take(24)
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) + _history.value
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun payInvoiceDirect(bolt11: String, sats: Long): PaymentResult {
|
||||||
|
val provider = providers().firstOrNull { it.isConfigured }
|
||||||
|
?: return PaymentResult.Failed("no wallet", "No wallet connected.")
|
||||||
|
return runCatching { provider.payInvoice(bolt11, sats) }
|
||||||
|
.getOrElse { PaymentResult.Failed("provider error: ${it.message}", "Payment failed.") }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createInvoice(sats: Long, memo: String?): PaymentResult {
|
||||||
|
val provider = providers().firstOrNull { it.isConfigured }
|
||||||
|
?: return PaymentResult.Failed("no wallet", "No wallet connected.")
|
||||||
|
return runCatching { provider.createInvoice(sats, memo) }
|
||||||
|
.getOrElse { PaymentResult.Failed("provider error: ${it.message}", "Invoice failed.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PaymentRecord(
|
||||||
|
val timestampMs: Long,
|
||||||
|
val artist: String,
|
||||||
|
val track: String?,
|
||||||
|
val sats: Long,
|
||||||
|
val method: String,
|
||||||
|
val success: Boolean,
|
||||||
|
val detail: String?,
|
||||||
|
)
|
||||||
285
app/src/main/java/com/rada/payments/ZapService.kt
Normal file
285
app/src/main/java/com/rada/payments/ZapService.kt
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
package com.rada.payments
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import com.rada.data.wavlake.WavlakeZap
|
||||||
|
import com.rada.nostr.MusicKinds
|
||||||
|
import com.rada.nostr.NostrEvent
|
||||||
|
import com.rada.nostr.NostrKeys
|
||||||
|
import com.rada.nostr.NostrService
|
||||||
|
import com.rada.nostr.hexToBytes
|
||||||
|
import com.rada.nostr.toHex
|
||||||
|
import fr.acinq.secp256k1.Secp256k1
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-57 zaps, end to end:
|
||||||
|
* target npub -> kind-0 profile -> lud16 -> LUD-06 lnurl ->
|
||||||
|
* zap request (kind 9734, signed by an ephemeral key per NIP-57) ->
|
||||||
|
* lnurl callback with `nostr=` -> bolt11 -> paid via [PaymentService]
|
||||||
|
* (NWC today; any provider later).
|
||||||
|
*
|
||||||
|
* Every failure is reported as ZapOutcome.Failed with a human line; the
|
||||||
|
* caller must treat zaps as strictly optional garnish on playback.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
private object StopCollect : Exception()
|
||||||
|
|
||||||
|
class ZapService @Inject constructor(
|
||||||
|
private val nostr: NostrService,
|
||||||
|
private val keys: NostrKeys,
|
||||||
|
private val paymentService: PaymentService,
|
||||||
|
) {
|
||||||
|
sealed interface ZapOutcome {
|
||||||
|
data object Sent : ZapOutcome
|
||||||
|
data class Failed(val reason: String) : ZapOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Official Wavlake track-zap outcome (no artist address involved). */
|
||||||
|
sealed interface WavlakeZapOutcome {
|
||||||
|
data object Sent : WavlakeZapOutcome
|
||||||
|
/** Invoice minted — payable in-app, or copied to any wallet. */
|
||||||
|
data class Invoice(val bolt11: String, val sats: Long, val requestId: String) : WavlakeZapOutcome
|
||||||
|
data class Failed(val reason: String) : WavlakeZapOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
private val http = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
suspend fun zapRecipient(
|
||||||
|
targetNpubHex: String,
|
||||||
|
sats: Long,
|
||||||
|
comment: String = "",
|
||||||
|
eventId: String? = null,
|
||||||
|
): ZapOutcome {
|
||||||
|
if (sats <= 0) return ZapOutcome.Failed("Enter an amount first.")
|
||||||
|
val relays = nostr.relaysConfig()
|
||||||
|
|
||||||
|
// 1. Target profile -> lud16 (lightning address).
|
||||||
|
val lud16: String? = withContext(Dispatchers.IO) {
|
||||||
|
var found: String? = null
|
||||||
|
try {
|
||||||
|
kotlinx.coroutines.withTimeout(6_000) {
|
||||||
|
nostr.getProfile(targetNpubHex).collect { ev ->
|
||||||
|
if (ev != null && found == null) {
|
||||||
|
found = JSONObject(ev.content).optString("lud16")
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
if (found != null) throw StopCollect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: StopCollect) { /* found set */ }
|
||||||
|
catch (e: kotlinx.coroutines.TimeoutCancellationException) { /* none */ }
|
||||||
|
found
|
||||||
|
} ?: return ZapOutcome.Failed("No Lightning address (lud16) on that profile.")
|
||||||
|
|
||||||
|
val address: String = lud16 ?: return ZapOutcome.Failed("No Lightning address set.")
|
||||||
|
return zapLightningAddress(address, sats, comment, targetNpubHex, eventId, relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun zapLightningAddress(
|
||||||
|
lud16: String,
|
||||||
|
sats: Long,
|
||||||
|
comment: String,
|
||||||
|
targetNpubHex: String,
|
||||||
|
eventId: String?,
|
||||||
|
relays: List<String>,
|
||||||
|
): ZapOutcome = withContext(Dispatchers.IO) {
|
||||||
|
// 2. LUD-06: https://domain/.well-known/lnurlp/name
|
||||||
|
val lnl = runCatching {
|
||||||
|
val (name, domain) = lud16.split("@", limit = 2)
|
||||||
|
http.newCall(
|
||||||
|
okhttp3.Request.Builder()
|
||||||
|
.url("https://$domain/.well-known/lnurlp/$name").build(),
|
||||||
|
).execute().use { r ->
|
||||||
|
if (!r.isSuccessful) return@runCatching null
|
||||||
|
JSONObject(r.body?.string() ?: return@runCatching null)
|
||||||
|
}
|
||||||
|
}.getOrNull() ?: return@withContext ZapOutcome.Failed("Couldn't reach the Lightning endpoint.")
|
||||||
|
|
||||||
|
if (lnl.optString("tag") != "payRequest")
|
||||||
|
return@withContext ZapOutcome.Failed("Endpoint isn't a pay request.")
|
||||||
|
|
||||||
|
val min = lnl.optLong("minSendable", 1000L) / 1000
|
||||||
|
val max = lnl.optLong("maxSendable", Long.MAX_VALUE) / 1000
|
||||||
|
if (sats < min || sats > max)
|
||||||
|
return@withContext ZapOutcome.Failed("Amount must be between $min and $max sats.")
|
||||||
|
|
||||||
|
// 3. Zap request event (NIP-57): signed by an EPHEMERAL key, not the
|
||||||
|
// user's identity key; `relays` tag points at our relays so the
|
||||||
|
// lnurl server can fetch it and verify.
|
||||||
|
val ephemeral = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||||
|
.also { if (!Secp256k1.get().secKeyVerify(it)) return@withContext ZapOutcome.Failed("rng busy, retry") }
|
||||||
|
val ephPub = Secp256k1.get().pubkeyCreate(ephemeral).copyOfRange(1, 33).toHex()
|
||||||
|
|
||||||
|
val tags = mutableListOf(
|
||||||
|
listOf("p", targetNpubHex),
|
||||||
|
listOf("relays", *relays.take(3).toTypedArray()),
|
||||||
|
listOf("amount", (sats * 1000).toString()),
|
||||||
|
)
|
||||||
|
if (eventId != null) tags.add(listOf("e", eventId))
|
||||||
|
|
||||||
|
val zapReq = NostrEvent.unsigned(
|
||||||
|
pubkey = ephPub, kind = MusicKinds.ZAP_REQUEST, content = comment, tags = tags,
|
||||||
|
).sign { digest -> Secp256k1.get().signSchnorr(digest, ephemeral, null).toHex() }
|
||||||
|
?: return@withContext ZapOutcome.Failed("Couldn't build the zap request.")
|
||||||
|
|
||||||
|
// Publish the zap request so the lnurl server can fetch it.
|
||||||
|
nostr.ensureConnected()
|
||||||
|
// (NostrService.publish signs with the *identity* key — for zap
|
||||||
|
// requests we must publish the pre-signed ephemeral event directly.)
|
||||||
|
nostr.publishRaw(zapReq)
|
||||||
|
|
||||||
|
// 4. Callback with nostr= param -> invoice.
|
||||||
|
val allowsNostr = lnl.optBoolean("allowsNostr", false)
|
||||||
|
if (!allowsNostr && comment.isNotEmpty()) {
|
||||||
|
// still payable without comment? proceed without nostr tag
|
||||||
|
}
|
||||||
|
val cb = lnl.optString("callback")
|
||||||
|
if (cb.isBlank()) return@withContext ZapOutcome.Failed("Endpoint missing callback.")
|
||||||
|
val url = buildString {
|
||||||
|
append(cb)
|
||||||
|
append(if (cb.contains("?")) "&" else "?")
|
||||||
|
append("amount=").append(sats * 1000)
|
||||||
|
if (allowsNostr) {
|
||||||
|
append("&nostr=").append(
|
||||||
|
java.net.URLEncoder.encode(zapReq.toJson(), "UTF-8"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val resp = runCatching {
|
||||||
|
http.newCall(okhttp3.Request.Builder().url(url).build())
|
||||||
|
.execute().use { r -> JSONObject(r.body?.string() ?: return@runCatching null) }
|
||||||
|
}.getOrNull() ?: return@withContext ZapOutcome.Failed("Zap callback unreachable.")
|
||||||
|
|
||||||
|
if (resp.has("error")) {
|
||||||
|
return@withContext ZapOutcome.Failed(resp.optString("reason", "Zap rejected."))
|
||||||
|
}
|
||||||
|
val bolt11 = resp.optString("pr")
|
||||||
|
if (bolt11.isBlank()) return@withContext ZapOutcome.Failed("No invoice returned.")
|
||||||
|
|
||||||
|
// 5. Pay via the connected wallet.
|
||||||
|
when (val pay = paymentService.payInvoiceDirect(bolt11, sats)) {
|
||||||
|
is PaymentResult.Paid -> {
|
||||||
|
nostr.waitForZapReceipt(zapReq.id, targetNpubHex, timeoutMs = 10_000)
|
||||||
|
ZapOutcome.Sent
|
||||||
|
}
|
||||||
|
is PaymentResult.Failed -> ZapOutcome.Failed(pay.userFacing)
|
||||||
|
else -> ZapOutcome.Failed("Unexpected wallet response.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Official Wavlake track zap (wavlake/mobile protocol): mint an invoice
|
||||||
|
* for a catalog track. Needs no artist address — the platform key is the
|
||||||
|
* recipient and Wavlake splits downstream. Wallet-free: returns the
|
||||||
|
* invoice for copy-out when no wallet is connected.
|
||||||
|
*/
|
||||||
|
suspend fun wavlakeTrackInvoice(
|
||||||
|
trackId: String,
|
||||||
|
albumId: String,
|
||||||
|
sats: Long,
|
||||||
|
comment: String = "",
|
||||||
|
): WavlakeZapOutcome = withContext(Dispatchers.IO) {
|
||||||
|
if (sats <= 0) return@withContext WavlakeZapOutcome.Failed("Enter an amount first.")
|
||||||
|
if (trackId.isBlank() || albumId.isBlank()) {
|
||||||
|
return@withContext WavlakeZapOutcome.Failed("This track can't be zapped yet.")
|
||||||
|
}
|
||||||
|
// Identity key preferred (official client behavior) when the private
|
||||||
|
// key is on this device; ephemeral per NIP-57 otherwise (including
|
||||||
|
// external-signer logins like Amber, which hold no local key).
|
||||||
|
// Either way a real schnorr signature.
|
||||||
|
val identityPub = keys.pubkey.value?.takeIf { it.isNotBlank() && keys.canSignLocally }
|
||||||
|
val ephemeral = if (identityPub == null) {
|
||||||
|
ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||||
|
.also {
|
||||||
|
if (!Secp256k1.get().secKeyVerify(it)) {
|
||||||
|
return@withContext WavlakeZapOutcome.Failed("rng busy, retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
val signerPub = identityPub
|
||||||
|
?: Secp256k1.get().pubkeyCreate(ephemeral!!).copyOfRange(1, 33).toHex()
|
||||||
|
val zapReq = WavlakeZap.buildZapRequest(
|
||||||
|
trackId = trackId,
|
||||||
|
albumId = albumId,
|
||||||
|
amountSats = sats,
|
||||||
|
comment = comment,
|
||||||
|
relays = nostr.relaysConfig(),
|
||||||
|
signerPubkeyHex = signerPub,
|
||||||
|
).sign { digest ->
|
||||||
|
if (ephemeral != null) Secp256k1.get().signSchnorr(digest, ephemeral, null).toHex()
|
||||||
|
else keys.signDigest(digest)
|
||||||
|
} ?: return@withContext WavlakeZapOutcome.Failed("Couldn't build the zap request.")
|
||||||
|
|
||||||
|
// Best-effort publish so relays (and receipts) can see it.
|
||||||
|
runCatching {
|
||||||
|
nostr.ensureConnected()
|
||||||
|
nostr.publishRaw(zapReq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed endpoint only — never follow redirects off-host (SSRF guard).
|
||||||
|
val invoice = runCatching {
|
||||||
|
val url = WavlakeZap.invoiceUrl(sats, zapReq.toJson())
|
||||||
|
val request = okhttp3.Request.Builder().url(url).build()
|
||||||
|
if (request.url.host != WavlakeZap.ZAP_HOST || request.url.scheme != "https") {
|
||||||
|
return@runCatching null
|
||||||
|
}
|
||||||
|
http.newCall(request).execute().use { r ->
|
||||||
|
if (!r.isSuccessful) return@runCatching null
|
||||||
|
val body = JSONObject(r.body?.string().orEmpty())
|
||||||
|
if (body.optString("status") == "ERROR" ||
|
||||||
|
body.optBoolean("success", true) == false
|
||||||
|
) {
|
||||||
|
return@withContext WavlakeZapOutcome.Failed(
|
||||||
|
body.optString("reason", body.optString("error", "Zap rejected.")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
body.optString("pr").takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
}.getOrNull() ?: return@withContext WavlakeZapOutcome.Failed("Couldn't reach the Lightning endpoint.")
|
||||||
|
if (!WavlakeZap.isPlausibleInvoice(invoice)) {
|
||||||
|
Log.w("Zap", "Implausible invoice from zap endpoint")
|
||||||
|
return@withContext WavlakeZapOutcome.Failed("Invalid invoice returned.")
|
||||||
|
}
|
||||||
|
WavlakeZapOutcome.Invoice(invoice, sats, zapReq.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full official track zap: invoice, then pay through the connected
|
||||||
|
* wallet, then wait for the receipt — success is reported only on
|
||||||
|
* confirmation, like the address flow.
|
||||||
|
*/
|
||||||
|
suspend fun zapWavlakeTrack(
|
||||||
|
trackId: String,
|
||||||
|
albumId: String,
|
||||||
|
sats: Long,
|
||||||
|
comment: String = "",
|
||||||
|
): WavlakeZapOutcome = withContext(Dispatchers.IO) {
|
||||||
|
val invoice = when (val r = wavlakeTrackInvoice(trackId, albumId, sats, comment)) {
|
||||||
|
is WavlakeZapOutcome.Invoice -> r
|
||||||
|
is WavlakeZapOutcome.Sent -> return@withContext r
|
||||||
|
is WavlakeZapOutcome.Failed -> return@withContext r
|
||||||
|
}
|
||||||
|
when (val pay = paymentService.payInvoiceDirect(invoice.bolt11, sats)) {
|
||||||
|
is PaymentResult.Paid -> {
|
||||||
|
nostr.waitForZapReceipt(
|
||||||
|
requestId = invoice.requestId,
|
||||||
|
targetPubkey = WavlakeZap.FEED_PUBKEY,
|
||||||
|
timeoutMs = 10_000,
|
||||||
|
)
|
||||||
|
WavlakeZapOutcome.Sent
|
||||||
|
}
|
||||||
|
is PaymentResult.Failed -> WavlakeZapOutcome.Failed(pay.userFacing)
|
||||||
|
else -> WavlakeZapOutcome.Failed("Unexpected wallet response.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,11 @@ package com.rada.player
|
||||||
|
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.AudioDeviceCallback
|
||||||
|
import android.media.AudioDeviceInfo
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
|
|
@ -62,6 +67,50 @@ class PlaybackManager(
|
||||||
private var reconnectJob: Job? = null
|
private var reconnectJob: Job? = null
|
||||||
private var reconnectAttempt = 0
|
private var reconnectAttempt = 0
|
||||||
private var readyAtMs = 0L
|
private var readyAtMs = 0L
|
||||||
|
/**
|
||||||
|
* Bluetooth A2DP route tracking: car stereos often drop A2DP while
|
||||||
|
* keeping hands-free connected (or vanish without the noisy broadcast),
|
||||||
|
* so engine-off doesn't reliably pause via ExoPlayer alone. When the
|
||||||
|
* last BT audio output disappears mid-playback, pause like Spotify.
|
||||||
|
* Needs no Bluetooth permissions — output-device observation only.
|
||||||
|
*/
|
||||||
|
private val audioManager: AudioManager? =
|
||||||
|
context.getSystemService(AudioManager::class.java)
|
||||||
|
private var wasBtA2dpConnected = false
|
||||||
|
private var deviceCallbackRegistered = false
|
||||||
|
private val deviceCallback = object : AudioDeviceCallback() {
|
||||||
|
override fun onAudioDevicesAdded(devices: Array<AudioDeviceInfo>) {
|
||||||
|
updateBtRoute()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioDevicesRemoved(devices: Array<AudioDeviceInfo>) {
|
||||||
|
val hadBt = wasBtA2dpConnected
|
||||||
|
updateBtRoute()
|
||||||
|
if (hadBt && !wasBtA2dpConnected && isPlaying) {
|
||||||
|
Log.d(TAG, "Bluetooth audio route lost — pausing")
|
||||||
|
try {
|
||||||
|
controller?.pause()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Pause on BT loss failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateBtRoute() {
|
||||||
|
wasBtA2dpConnected = audioManager
|
||||||
|
?.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||||
|
.orEmpty()
|
||||||
|
.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP }
|
||||||
|
}
|
||||||
|
/** Delayed ICY-reader-only restart (see startIcyReader listener). */
|
||||||
|
private var icyRestartJob: Job? = null
|
||||||
|
/**
|
||||||
|
* True after an explicit user Stop. Guards the auto-reconnect path: a
|
||||||
|
* late player error must never resurrect a stream the user stopped.
|
||||||
|
* Cleared whenever new playback starts.
|
||||||
|
*/
|
||||||
|
private var userStopped = false
|
||||||
|
|
||||||
var listener: PlaybackListener? = null
|
var listener: PlaybackListener? = null
|
||||||
|
|
||||||
|
|
@ -90,6 +139,71 @@ class PlaybackManager(
|
||||||
private val _lastStreamMetaFlow = MutableStateFlow<StreamMetadata?>(null)
|
private val _lastStreamMetaFlow = MutableStateFlow<StreamMetadata?>(null)
|
||||||
val lastStreamMetaFlow: StateFlow<StreamMetadata?> = _lastStreamMetaFlow.asStateFlow()
|
val lastStreamMetaFlow: StateFlow<StreamMetadata?> = _lastStreamMetaFlow.asStateFlow()
|
||||||
|
|
||||||
|
// ── Queue / repeat / shuffle / speed / sleep timer ─────────────────
|
||||||
|
/** Playback speed, 0.5×–2.0×. Survives station and track changes. */
|
||||||
|
private val _speedFlow = MutableStateFlow(1.0f)
|
||||||
|
val speedFlow: StateFlow<Float> = _speedFlow.asStateFlow()
|
||||||
|
|
||||||
|
private val _repeatModeFlow = MutableStateFlow(androidx.media3.common.Player.REPEAT_MODE_OFF)
|
||||||
|
val repeatModeFlow: StateFlow<Int> = _repeatModeFlow.asStateFlow()
|
||||||
|
|
||||||
|
private val _shuffleEnabledFlow = MutableStateFlow(false)
|
||||||
|
val shuffleEnabledFlow: StateFlow<Boolean> = _shuffleEnabledFlow.asStateFlow()
|
||||||
|
|
||||||
|
/** Milliseconds left on the sleep timer; 0 when off. */
|
||||||
|
private val _sleepRemainingMsFlow = MutableStateFlow(0L)
|
||||||
|
val sleepRemainingMsFlow: StateFlow<Long> = _sleepRemainingMsFlow.asStateFlow()
|
||||||
|
|
||||||
|
/** Library song id of the current queue item (null when not queue-playing). */
|
||||||
|
private val _currentLocalTrackIdFlow = MutableStateFlow<Long?>(null)
|
||||||
|
val currentLocalTrackIdFlow: StateFlow<Long?> = _currentLocalTrackIdFlow.asStateFlow()
|
||||||
|
|
||||||
|
private var sleepJob: Job? = null
|
||||||
|
|
||||||
|
fun setSpeed(speed: Float) {
|
||||||
|
val clamped = speed.coerceIn(0.5f, 2.0f)
|
||||||
|
_speedFlow.value = clamped
|
||||||
|
controller?.setPlaybackSpeed(clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRepeatMode(@androidx.media3.common.Player.RepeatMode mode: Int) { controller?.repeatMode = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShuffleEnabled(enabled: Boolean) {
|
||||||
|
controller?.shuffleModeEnabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start (or restart) the sleep timer; pauses playback when it fires. */
|
||||||
|
fun startSleepTimer(minutes: Int) {
|
||||||
|
sleepJob?.cancel()
|
||||||
|
if (minutes <= 0) {
|
||||||
|
_sleepRemainingMsFlow.value = 0L
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val totalMs = minutes * 60_000L
|
||||||
|
val endAt = SystemClock.elapsedRealtime() + totalMs
|
||||||
|
sleepJob = scope.launch {
|
||||||
|
while (true) {
|
||||||
|
val remaining = endAt - SystemClock.elapsedRealtime()
|
||||||
|
if (remaining <= 0L) break
|
||||||
|
_sleepRemainingMsFlow.value = remaining
|
||||||
|
delay(1_000L)
|
||||||
|
}
|
||||||
|
_sleepRemainingMsFlow.value = 0L
|
||||||
|
sleepJob = null
|
||||||
|
// Pause rather than stop: the station stays loaded for when the
|
||||||
|
// user wakes up. Controller calls are main-thread confined.
|
||||||
|
withContext(Dispatchers.Main) { controller?.pause() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelSleepTimer() {
|
||||||
|
sleepJob?.cancel()
|
||||||
|
sleepJob = null
|
||||||
|
_sleepRemainingMsFlow.value = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private val _bufferStateFlow = MutableStateFlow(BufferState.IDLE)
|
private val _bufferStateFlow = MutableStateFlow(BufferState.IDLE)
|
||||||
val bufferStateFlow: StateFlow<BufferState> = _bufferStateFlow.asStateFlow()
|
val bufferStateFlow: StateFlow<BufferState> = _bufferStateFlow.asStateFlow()
|
||||||
|
|
||||||
|
|
@ -116,6 +230,19 @@ class PlaybackManager(
|
||||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
saveSongUseCase = SaveSongUseCase(context, libraryRepository)
|
saveSongUseCase = SaveSongUseCase(context, libraryRepository)
|
||||||
|
|
||||||
|
if (!deviceCallbackRegistered) {
|
||||||
|
updateBtRoute()
|
||||||
|
try {
|
||||||
|
audioManager?.registerAudioDeviceCallback(
|
||||||
|
deviceCallback,
|
||||||
|
Handler(Looper.getMainLooper()),
|
||||||
|
)
|
||||||
|
deviceCallbackRegistered = true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "BT route tracking unavailable", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val sessionToken = SessionToken(
|
val sessionToken = SessionToken(
|
||||||
context,
|
context,
|
||||||
ComponentName(context, PlaybackService::class.java)
|
ComponentName(context, PlaybackService::class.java)
|
||||||
|
|
@ -128,6 +255,13 @@ class PlaybackManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
|
if (deviceCallbackRegistered) {
|
||||||
|
try {
|
||||||
|
audioManager?.unregisterAudioDeviceCallback(deviceCallback)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
deviceCallbackRegistered = false
|
||||||
|
}
|
||||||
stopAll()
|
stopAll()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
controller?.removeListener(playerListener)
|
controller?.removeListener(playerListener)
|
||||||
|
|
@ -139,12 +273,17 @@ class PlaybackManager(
|
||||||
fun playStation(url: String, name: String) {
|
fun playStation(url: String, name: String) {
|
||||||
currentStationUrl = url
|
currentStationUrl = url
|
||||||
currentStationName = name
|
currentStationName = name
|
||||||
|
userStopped = false
|
||||||
_currentStationUrlFlow.value = url
|
_currentStationUrlFlow.value = url
|
||||||
_currentStationNameFlow.value = name
|
_currentStationNameFlow.value = name
|
||||||
_isLocalFlow.value = false
|
_isLocalFlow.value = false
|
||||||
_localMediaFlow.value = null
|
_localMediaFlow.value = null
|
||||||
_lastStreamMetaFlow.value = null
|
_lastStreamMetaFlow.value = null
|
||||||
|
_currentLocalTrackIdFlow.value = null
|
||||||
val ctrl = controller ?: return
|
val ctrl = controller ?: return
|
||||||
|
// A live stream has no queue: shuffle must not leak from local playback.
|
||||||
|
ctrl.shuffleModeEnabled = false
|
||||||
|
_shuffleEnabledFlow.value = false
|
||||||
|
|
||||||
stopAll()
|
stopAll()
|
||||||
initBuffer()
|
initBuffer()
|
||||||
|
|
@ -162,46 +301,92 @@ class PlaybackManager(
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
ctrl.setMediaItem(mediaItem)
|
ctrl.setMediaItem(mediaItem)
|
||||||
|
ctrl.setPlaybackSpeed(_speedFlow.value)
|
||||||
ctrl.prepare()
|
ctrl.prepare()
|
||||||
ctrl.play()
|
ctrl.play()
|
||||||
|
|
||||||
startIcyReader(url)
|
startIcyReader(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playLocalFile(filePath: String, title: String, artist: String) {
|
fun playLocalFile(filePath: String, title: String, artist: String, id: Long? = null) {
|
||||||
|
playLocalQueue(listOf(LocalTrack(id, filePath, title, artist)), startIndex = 0, shuffle = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A local library track for queue playback. `id` links UI state to the player. */
|
||||||
|
data class LocalTrack(val id: Long?, val filePath: String, val title: String, val artist: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays a local queue (library songs / playlist). ExoPlayer handles
|
||||||
|
* advance, shuffle order and repeat natively, so transport next/prev
|
||||||
|
* and end-of-track behave like a real music player.
|
||||||
|
*/
|
||||||
|
fun playLocalQueue(tracks: List<LocalTrack>, startIndex: Int, shuffle: Boolean) {
|
||||||
|
if (tracks.isEmpty()) return
|
||||||
|
userStopped = false
|
||||||
currentStationUrl = null
|
currentStationUrl = null
|
||||||
currentStationName = ""
|
currentStationName = ""
|
||||||
// Publish so the shared NowPlaying bar shows library playback too.
|
|
||||||
_currentStationNameFlow.value = if (artist.isBlank()) title else "$title — $artist"
|
|
||||||
_currentStationUrlFlow.value = null
|
_currentStationUrlFlow.value = null
|
||||||
_isLocalFlow.value = true
|
_isLocalFlow.value = true
|
||||||
_localMediaFlow.value = title to artist
|
val first = tracks[startIndex.coerceIn(0, tracks.lastIndex)]
|
||||||
|
_currentStationNameFlow.value =
|
||||||
|
if (first.artist.isBlank()) first.title else "${first.title} — ${first.artist}"
|
||||||
|
_localMediaFlow.value = first.title to first.artist
|
||||||
_lastStreamMetaFlow.value = null
|
_lastStreamMetaFlow.value = null
|
||||||
val ctrl = controller ?: return
|
val ctrl = controller ?: return
|
||||||
|
|
||||||
stopAll()
|
stopAll()
|
||||||
|
|
||||||
val mediaItem = MediaItem.Builder()
|
val items = tracks.map { track ->
|
||||||
.setUri(filePath)
|
MediaItem.Builder()
|
||||||
.setMediaMetadata(
|
.setUri(track.filePath)
|
||||||
MediaMetadata.Builder()
|
.setMediaId(track.id?.toString() ?: track.filePath)
|
||||||
.setArtist(artist)
|
.setMediaMetadata(
|
||||||
.setTitle(title)
|
MediaMetadata.Builder()
|
||||||
.build()
|
.setArtist(track.artist)
|
||||||
)
|
.setTitle(track.title)
|
||||||
.build()
|
.build()
|
||||||
|
)
|
||||||
ctrl.setMediaItem(mediaItem)
|
.build()
|
||||||
|
}
|
||||||
|
ctrl.setMediaItems(items, startIndex.coerceIn(0, items.lastIndex), 0L)
|
||||||
|
ctrl.shuffleModeEnabled = shuffle
|
||||||
|
_shuffleEnabledFlow.value = shuffle
|
||||||
|
ctrl.setPlaybackSpeed(_speedFlow.value)
|
||||||
ctrl.prepare()
|
ctrl.prepare()
|
||||||
ctrl.play()
|
ctrl.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Transport next within the current queue (no-op for a single stream). */
|
||||||
|
fun seekToNextItem() {
|
||||||
|
controller?.let { if (it.mediaItemCount > 1) it.seekToNext() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekToPreviousItem() {
|
||||||
|
controller?.let { if (it.mediaItemCount > 1) it.seekToPrevious() }
|
||||||
|
}
|
||||||
|
|
||||||
fun togglePlayPause() {
|
fun togglePlayPause() {
|
||||||
val ctrl = controller ?: return
|
val ctrl = controller ?: return
|
||||||
if (ctrl.isPlaying) ctrl.pause() else ctrl.play()
|
if (ctrl.isPlaying) {
|
||||||
|
ctrl.pause()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Resume-from-stop restarts the station fully: stop() tears down the
|
||||||
|
// recorder (buffer IDLE, reader stopped), so a bare play() would
|
||||||
|
// bring back audio with a dead save button. A normal pause (buffer
|
||||||
|
// READY) resumes directly.
|
||||||
|
val url = currentStationUrl
|
||||||
|
if (!_isLocalFlow.value && url != null &&
|
||||||
|
(bufferState == BufferState.IDLE || bufferState == BufferState.ERROR)
|
||||||
|
) {
|
||||||
|
playStation(url, currentStationName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctrl.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
userStopped = true
|
||||||
stopAll()
|
stopAll()
|
||||||
controller?.stop()
|
controller?.stop()
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
|
|
@ -221,12 +406,22 @@ class PlaybackManager(
|
||||||
updateSaveState(SaveState.SAVING, null)
|
updateSaveState(SaveState.SAVING, null)
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val result = saveSongUseCase.save(
|
// A throw anywhere below must still reset saveState (and surface),
|
||||||
buffer = buffer,
|
// otherwise every later tap hits the SAVING guard silently.
|
||||||
metadataTimeline = metadataTimeline,
|
val result = try {
|
||||||
currentMetadata = metadataTimeline.currentMetadata,
|
saveSongUseCase.save(
|
||||||
stationName = currentStationName,
|
buffer = buffer,
|
||||||
)
|
metadataTimeline = metadataTimeline,
|
||||||
|
currentMetadata = metadataTimeline.currentMetadata,
|
||||||
|
stationName = currentStationName,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Save failed", e)
|
||||||
|
SaveSongUseCase.SaveResult(
|
||||||
|
success = false,
|
||||||
|
error = "Save failed: ${e.message ?: e.javaClass.simpleName}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
lastSaveResult = result
|
lastSaveResult = result
|
||||||
|
|
@ -301,8 +496,34 @@ class PlaybackManager(
|
||||||
|
|
||||||
override fun onError(error: String) {
|
override fun onError(error: String) {
|
||||||
Log.w(TAG, "ICY reader error: $error")
|
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
|
||||||
|
// otherwise go stale silently (frozen buffer, dead save).
|
||||||
|
// Restart just the reader while a station is still wanted.
|
||||||
|
// NOTE: `isPlaying` is the wrong signal here — it is false
|
||||||
|
// while stalled/buffering, exactly when a restart is needed.
|
||||||
|
// Real deaths go through the full reconnect path, and
|
||||||
|
// stopAll() cancels this job.
|
||||||
|
if (!userStopped && !_isLocalFlow.value &&
|
||||||
|
icyRestartJob?.isActive != true
|
||||||
|
) {
|
||||||
|
val url = currentStationUrl
|
||||||
|
if (url != null) {
|
||||||
|
icyRestartJob = runCatching {
|
||||||
|
scope.launch {
|
||||||
|
delay(ICY_RESTART_DELAY_MS)
|
||||||
|
if (!userStopped && !_isLocalFlow.value &&
|
||||||
|
currentStationUrl == url
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "Restarting ICY reader")
|
||||||
|
startIcyReader(url)
|
||||||
|
}
|
||||||
|
icyRestartJob = null
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onConnected(metaInterval: Int) {
|
override fun onConnected(metaInterval: Int) {
|
||||||
Log.d(TAG, "ICY connected, meta interval: $metaInterval bytes")
|
Log.d(TAG, "ICY connected, meta interval: $metaInterval bytes")
|
||||||
}
|
}
|
||||||
|
|
@ -312,6 +533,8 @@ class PlaybackManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopIcyReader() {
|
private fun stopIcyReader() {
|
||||||
|
icyRestartJob?.cancel()
|
||||||
|
icyRestartJob = null
|
||||||
icyReader?.stop()
|
icyReader?.stop()
|
||||||
icyReader = null
|
icyReader = null
|
||||||
}
|
}
|
||||||
|
|
@ -334,6 +557,27 @@ class PlaybackManager(
|
||||||
listener?.onPlaybackStateChanged(playing)
|
listener?.onPlaybackStateChanged(playing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onRepeatModeChanged(mode: Int) {
|
||||||
|
_repeatModeFlow.value = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShuffleModeEnabledChanged(enabled: Boolean) {
|
||||||
|
_shuffleEnabledFlow.value = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue advanced (auto or manual): publish the new item's identity. */
|
||||||
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||||
|
if (_isLocalFlow.value && mediaItem != null) {
|
||||||
|
val title = mediaItem.mediaMetadata.title?.toString().orEmpty()
|
||||||
|
val artist = mediaItem.mediaMetadata.artist?.toString().orEmpty()
|
||||||
|
_localMediaFlow.value = title to artist
|
||||||
|
_currentStationNameFlow.value =
|
||||||
|
if (artist.isBlank()) title else "$title — $artist"
|
||||||
|
val itemId = mediaItem.mediaId.toLongOrNull()
|
||||||
|
_currentLocalTrackIdFlow.value = itemId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
when (playbackState) {
|
when (playbackState) {
|
||||||
Player.STATE_READY -> {
|
Player.STATE_READY -> {
|
||||||
|
|
@ -342,11 +586,20 @@ class PlaybackManager(
|
||||||
}
|
}
|
||||||
Player.STATE_BUFFERING -> listener?.onPlaybackStateChanged(false)
|
Player.STATE_BUFFERING -> listener?.onPlaybackStateChanged(false)
|
||||||
Player.STATE_IDLE -> listener?.onPlaybackStateChanged(false)
|
Player.STATE_IDLE -> listener?.onPlaybackStateChanged(false)
|
||||||
Player.STATE_ENDED -> listener?.onPlaybackStateChanged(false)
|
Player.STATE_ENDED -> {
|
||||||
|
// A finished track is not playing: without this the UI
|
||||||
|
// keeps a stale pause icon over silence.
|
||||||
|
isPlaying = false
|
||||||
|
_isPlayingFlow.value = false
|
||||||
|
listener?.onPlaybackStateChanged(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
|
// A late error for playback the user already stopped is stale
|
||||||
|
// noise: the buffer is IDLE and no reconnect is wanted.
|
||||||
|
if (userStopped) return
|
||||||
val description = describeError(error)
|
val description = describeError(error)
|
||||||
updateBufferState(BufferState.ERROR)
|
updateBufferState(BufferState.ERROR)
|
||||||
stopIcyReader()
|
stopIcyReader()
|
||||||
|
|
@ -375,6 +628,9 @@ class PlaybackManager(
|
||||||
|
|
||||||
private fun scheduleReconnect(): Long? {
|
private fun scheduleReconnect(): Long? {
|
||||||
val url = currentStationUrl ?: return null
|
val url = currentStationUrl ?: return null
|
||||||
|
// Never resurrect playback the user explicitly stopped, or a queue
|
||||||
|
// they have since replaced with local tracks.
|
||||||
|
if (userStopped || _isLocalFlow.value) return null
|
||||||
if (reconnectJob?.isActive == true) return null
|
if (reconnectJob?.isActive == true) return null
|
||||||
|
|
||||||
if (readyAtMs != 0L && SystemClock.elapsedRealtime() - readyAtMs > RECONNECT_STABLE_MS) {
|
if (readyAtMs != 0L && SystemClock.elapsedRealtime() - readyAtMs > RECONNECT_STABLE_MS) {
|
||||||
|
|
@ -388,9 +644,23 @@ class PlaybackManager(
|
||||||
|
|
||||||
reconnectJob = scope.launch {
|
reconnectJob = scope.launch {
|
||||||
delay(delayMs)
|
delay(delayMs)
|
||||||
if (currentStationUrl == url) {
|
// MediaController is application-thread confined: marshal the
|
||||||
|
// retry onto Main. Calling playStation() here on Dispatchers.IO
|
||||||
|
// used to crash the whole process with IllegalStateException on
|
||||||
|
// the first stream drop instead of recovering from it.
|
||||||
|
if (currentStationUrl == url && !userStopped && !_isLocalFlow.value) {
|
||||||
|
reconnectJob = null
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (currentStationUrl == url && !userStopped && !_isLocalFlow.value) {
|
||||||
|
playStation(url, currentStationName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Reconnect failed", e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
playStation(url, currentStationName)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return delayMs
|
return delayMs
|
||||||
|
|
@ -401,5 +671,6 @@ class PlaybackManager(
|
||||||
private const val RECONNECT_BASE_DELAY_MS = 1000L
|
private const val RECONNECT_BASE_DELAY_MS = 1000L
|
||||||
private const val RECONNECT_MAX_DELAY_MS = 30_000L
|
private const val RECONNECT_MAX_DELAY_MS = 30_000L
|
||||||
private const val RECONNECT_STABLE_MS = 10_000L
|
private const val RECONNECT_STABLE_MS = 10_000L
|
||||||
|
private const val ICY_RESTART_DELAY_MS = 5_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,40 @@ package com.rada.player
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.AudioAttributes
|
||||||
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
import androidx.media3.session.MediaSessionService
|
import androidx.media3.session.MediaSessionService
|
||||||
import com.rada.ui.MainActivity
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import com.rada.ui.MainActivity
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class PlaybackService : MediaSessionService() {
|
class PlaybackService : MediaSessionService() {
|
||||||
|
|
||||||
private var mediaSession: MediaSession? = null
|
private var mediaSession: MediaSession? = null
|
||||||
|
|
||||||
|
@Inject lateinit var playbackManager: PlaybackManager
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
val player = ExoPlayer.Builder(this).build()
|
// Spotify contract: the stream is classified as media and the player
|
||||||
|
// yields audio focus (pauses for calls, ducks for transient
|
||||||
|
// notifications) and resumes when focus returns. Without this, phone
|
||||||
|
// calls never pause the music and Bluetooth can't hand over to them.
|
||||||
|
val audioAttributes = AudioAttributes.Builder()
|
||||||
|
.setUsage(C.USAGE_MEDIA)
|
||||||
|
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||||
|
.build()
|
||||||
|
val player = ExoPlayer.Builder(this)
|
||||||
|
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
||||||
|
.setHandleAudioBecomingNoisy(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
val intent = Intent(this, MainActivity::class.java)
|
val intent = Intent(this, MainActivity::class.java)
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
|
@ -38,9 +54,21 @@ class PlaybackService : MediaSessionService() {
|
||||||
return mediaSession
|
return mediaSession
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closed means stopped: swiping the task away is an explicit kill, so
|
||||||
|
* stop playback (audio + recorder) and drop the notification. Screen-off
|
||||||
|
* pocket listening is unaffected — the task still exists then.
|
||||||
|
*/
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
val player = mediaSession?.player
|
try {
|
||||||
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
|
playbackManager.stop()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
try {
|
||||||
|
mediaSession?.player?.pause()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Nothing more we can do; still stop below.
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,10 @@ class MetadataEmbedder(private val context: Context) {
|
||||||
// Store the jaudiotagger AudioFile reference (Bug #6).
|
// Store the jaudiotagger AudioFile reference (Bug #6).
|
||||||
audio = AudioFileIO.read(audioFile)
|
audio = AudioFileIO.read(audioFile)
|
||||||
|
|
||||||
// Set metadata fields on the stored reference's tag.
|
// Extracted segments are raw MP3 frames with NO tag yet, and
|
||||||
val tag = audio.tag
|
// getTag() returns null for them — the old NullPointerException.
|
||||||
|
// getTagOrCreateAndSetDefault() creates an ID3v2 tag instead.
|
||||||
|
val tag = audio.tagOrCreateAndSetDefault
|
||||||
tag.setField(FieldKey.ARTIST, artist.ifEmpty { "Unknown Artist" })
|
tag.setField(FieldKey.ARTIST, artist.ifEmpty { "Unknown Artist" })
|
||||||
tag.setField(FieldKey.TITLE, title.ifEmpty { "Unknown Title" })
|
tag.setField(FieldKey.TITLE, title.ifEmpty { "Unknown Title" })
|
||||||
tag.setField(FieldKey.ALBUM, stationName.ifEmpty { "SatsAmp" })
|
tag.setField(FieldKey.ALBUM, stationName.ifEmpty { "SatsAmp" })
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,21 @@ class SaveSongUseCase(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refuse duplicates before touching storage: the same artist+title is
|
||||||
|
// already in the library (case-insensitive, trimmed).
|
||||||
|
val dupArtist = boundaries.artist.trim()
|
||||||
|
val dupTitle = boundaries.title.trim()
|
||||||
|
if (dupArtist.isNotEmpty() && dupTitle.isNotEmpty()) {
|
||||||
|
val existing = libraryRepository.getByArtistTitle(dupArtist, dupTitle)
|
||||||
|
if (existing != null) {
|
||||||
|
return SaveResult(
|
||||||
|
success = false,
|
||||||
|
songId = existing.id,
|
||||||
|
error = "Already in your library: $dupTitle",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check minimum length (at least 10 seconds at 128kbps = 160,000 bytes)
|
// Check minimum length (at least 10 seconds at 128kbps = 160,000 bytes)
|
||||||
val minBytes = 160_000L
|
val minBytes = 160_000L
|
||||||
if (boundaries.durationBytes < minBytes) {
|
if (boundaries.durationBytes < minBytes) {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
package com.rada.ui
|
package com.rada.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.rada.player.PlaybackManager
|
import com.rada.player.PlaybackManager
|
||||||
import com.rada.ui.navigation.NavGraph
|
import com.rada.ui.navigation.NavGraph
|
||||||
|
|
@ -22,10 +27,22 @@ class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
@Inject lateinit var playbackManager: PlaybackManager
|
@Inject lateinit var playbackManager: PlaybackManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playback controls + the way back into the app after swiping it away
|
||||||
|
* live in the media notification — which Android 13+ hides until the
|
||||||
|
* user grants POST_NOTIFICATIONS. Ask once per install on first launch;
|
||||||
|
* a denial just means no notification (playback still works).
|
||||||
|
*/
|
||||||
|
private val notificationPermission =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
getPreferences(MODE_PRIVATE).edit().putBoolean(PREF_NOTIF_ASKED, true).apply()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
playbackManager.connect()
|
playbackManager.connect()
|
||||||
|
askNotificationPermissionOnce()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SatsAmpTheme {
|
SatsAmpTheme {
|
||||||
|
|
@ -45,4 +62,21 @@ class MainActivity : ComponentActivity() {
|
||||||
playbackManager.disconnect()
|
playbackManager.disconnect()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun askNotificationPermissionOnce() {
|
||||||
|
if (Build.VERSION.SDK_INT < 33) return
|
||||||
|
val prefs = getPreferences(MODE_PRIVATE)
|
||||||
|
if (prefs.getBoolean(PREF_NOTIF_ASKED, false)) return
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
prefs.edit().putBoolean(PREF_NOTIF_ASKED, true).apply()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREF_NOTIF_ASKED = "notif_asked"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
78
app/src/main/java/com/rada/ui/components/EqualizerBars.kt
Normal file
78
app/src/main/java/com/rada/ui/components/EqualizerBars.kt
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
package com.rada.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.rada.ui.theme.LocalReduceMotion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three-bar "now playing" indicator — the compact signal that a row is
|
||||||
|
* the active source (Spotify's equalizer). Bars pulse out of phase; paused
|
||||||
|
* freezes them mid-height so the shape still reads as "the equalizer", and
|
||||||
|
* reduce-motion renders a static, even-bar version.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EqualizerBars(
|
||||||
|
active: Boolean,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
barWidth: Dp = 2.5.dp,
|
||||||
|
) {
|
||||||
|
val reduce = LocalReduceMotion.current
|
||||||
|
val transition = rememberInfiniteTransition(label = "eq")
|
||||||
|
// Out-of-phase sine-ish pulses; different durations keep bars unsynced.
|
||||||
|
val b1 by transition.animateFloat(
|
||||||
|
initialValue = 0.25f, targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(520), repeatMode = RepeatMode.Reverse,
|
||||||
|
), label = "b1",
|
||||||
|
)
|
||||||
|
val b2 by transition.animateFloat(
|
||||||
|
initialValue = 1f, targetValue = 0.3f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(660), repeatMode = RepeatMode.Reverse,
|
||||||
|
), label = "b2",
|
||||||
|
)
|
||||||
|
val b3 by transition.animateFloat(
|
||||||
|
initialValue = 0.45f, targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(440), repeatMode = RepeatMode.Reverse,
|
||||||
|
), label = "b3",
|
||||||
|
)
|
||||||
|
// Frozen heights for the paused pose (mid-range, uneven = still "eq").
|
||||||
|
val h1 = if (reduce || !active) 0.6f else b1
|
||||||
|
val h2 = if (reduce || !active) 1f else b2
|
||||||
|
val h3 = if (reduce || !active) 0.45f else b3
|
||||||
|
|
||||||
|
Canvas(modifier = modifier.size(14.dp)) {
|
||||||
|
val gap = this.size.width / 8f
|
||||||
|
val w = barWidth.toPx()
|
||||||
|
val totalW = w * 3 + gap * 2
|
||||||
|
var x = (this.size.width - totalW) / 2f
|
||||||
|
val r = CornerRadius(w / 2f, w / 2f)
|
||||||
|
for (h in floatArrayOf(h1, h2, h3)) {
|
||||||
|
val barH = this.size.height * h
|
||||||
|
drawRoundRect(
|
||||||
|
color = color,
|
||||||
|
topLeft = Offset(x, this.size.height - barH),
|
||||||
|
size = Size(w, barH),
|
||||||
|
cornerRadius = r,
|
||||||
|
)
|
||||||
|
x += w + gap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,381 @@
|
||||||
|
package com.rada.ui.decentralized
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
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.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.rada.ui.theme.LocalTokens
|
||||||
|
import com.rada.ui.theme.SatsAmpGlass
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decentralized settings sheet — progressive disclosure per the design:
|
||||||
|
* listeners see "identity + wallet", advanced rows (relays, profile) sit
|
||||||
|
* behind a toggle. All state via [DecentralizedViewModel].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DecentralizedSheet(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
viewModel: DecentralizedViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val tokens = LocalTokens.current
|
||||||
|
val npub by viewModel.npub.collectAsState()
|
||||||
|
val walletConnected by viewModel.walletConnected.collectAsState()
|
||||||
|
val relays by viewModel.relays.collectAsState()
|
||||||
|
val message by viewModel.message.collectAsState()
|
||||||
|
val busy by viewModel.busy.collectAsState()
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
|
||||||
|
val signerLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { res ->
|
||||||
|
if (res.resultCode != Activity.RESULT_OK) {
|
||||||
|
viewModel.onSignerError()
|
||||||
|
} else {
|
||||||
|
val rejected = res.data?.getBooleanExtra("rejected", false) == true
|
||||||
|
viewModel.onSignerResult(
|
||||||
|
res.data?.getStringExtra("result"),
|
||||||
|
res.data?.getStringExtra("package"),
|
||||||
|
rejected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var showAdvanced by remember { mutableStateOf(false) }
|
||||||
|
var showImport by remember { mutableStateOf(false) }
|
||||||
|
var showPubkeyImport by remember { mutableStateOf(false) }
|
||||||
|
var nsecInput by remember { mutableStateOf("") }
|
||||||
|
var pubkeyInput by remember { mutableStateOf("") }
|
||||||
|
var nwcInput by remember { mutableStateOf("") }
|
||||||
|
var nameInput by remember { mutableStateOf("") }
|
||||||
|
var aboutInput by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
containerColor = tokens.panel,
|
||||||
|
titleContentColor = tokens.text,
|
||||||
|
shape = RoundedCornerShape(SatsAmpGlass.rCard),
|
||||||
|
title = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Nostr", fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// ── Nostr identity ────────────────────────────────────
|
||||||
|
SectionLabel("Nostr identity")
|
||||||
|
val pk = npub
|
||||||
|
if (pk == null) {
|
||||||
|
Text(
|
||||||
|
"Optional. Gives you a profile, follows, reactions and zaps. Everything else in SatsAmp works without it.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = tokens.textSecondary,
|
||||||
|
)
|
||||||
|
// Always shown: if no signer is installed the launch fails
|
||||||
|
// and the user gets a clear message instead of a missing button.
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
runCatching {
|
||||||
|
signerLauncher.launch(viewModel.amberLoginIntent())
|
||||||
|
}.onFailure {
|
||||||
|
if (viewModel.isSignerInstalled) viewModel.onSignerError()
|
||||||
|
else viewModel.onSignerNotFound()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = tokens.accentFill,
|
||||||
|
contentColor = tokens.accentOnFill,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(SatsAmpGlass.rInput),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Login with Amber") }
|
||||||
|
if (!viewModel.isSignerInstalled) {
|
||||||
|
Text(
|
||||||
|
"No signer app detected — install Amber on this phone, or import your npub below.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = tokens.textSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.createIdentity() },
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = tokens.accentFill,
|
||||||
|
contentColor = tokens.accentOnFill,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(SatsAmpGlass.rInput),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Create Nostr identity") }
|
||||||
|
TextButton(onClick = { showImport = !showImport }) {
|
||||||
|
Text("Import existing nsec", color = tokens.accentText)
|
||||||
|
}
|
||||||
|
TextButton(onClick = { showPubkeyImport = !showPubkeyImport }) {
|
||||||
|
Text("Import pubkey (e.g. from Amber)", color = tokens.accentText)
|
||||||
|
}
|
||||||
|
if (showImport) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = nsecInput,
|
||||||
|
onValueChange = { nsecInput = it },
|
||||||
|
placeholder = { Text("nsec… (stored encrypted)") },
|
||||||
|
singleLine = true,
|
||||||
|
colors = fieldColors(tokens),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.importNsec(nsecInput) },
|
||||||
|
enabled = nsecInput.isNotBlank(),
|
||||||
|
) { Text("Import") }
|
||||||
|
}
|
||||||
|
if (showPubkeyImport) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = pubkeyInput,
|
||||||
|
onValueChange = { pubkeyInput = it },
|
||||||
|
placeholder = { Text("npub1… (from Amber or another wallet)") },
|
||||||
|
singleLine = true,
|
||||||
|
colors = fieldColors(tokens),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (pubkeyInput.isNotBlank()) {
|
||||||
|
viewModel.importPubkey(pubkeyInput)
|
||||||
|
pubkeyInput = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = pubkeyInput.isNotBlank(),
|
||||||
|
) { Text("Import pubkey") }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(tokens.glassFillStrong)
|
||||||
|
.border(1.dp, tokens.border, RoundedCornerShape(12.dp))
|
||||||
|
.clickable { clipboard.setText(AnnotatedString(pk)) }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
pk,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = tokens.text,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ContentCopy, contentDescription = "Copy npub",
|
||||||
|
tint = tokens.textSecondary,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"tap to copy",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = tokens.textMuted,
|
||||||
|
)
|
||||||
|
TextButton(onClick = { viewModel.signOut() }) {
|
||||||
|
Text("Sign out", color = tokens.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// ── Wallet ────────────────────────────────────────────
|
||||||
|
SectionLabel("Lightning wallet")
|
||||||
|
if (!walletConnected) {
|
||||||
|
Text(
|
||||||
|
"Connect a NIP-47 wallet (Alby Hub, Coinos, etc.) with its pairing string to zap and tip artists.",
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.connectWallet(nwcInput) },
|
||||||
|
enabled = nwcInput.isNotBlank() && busy.not(),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = tokens.accentFill,
|
||||||
|
contentColor = tokens.accentOnFill,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(SatsAmpGlass.rInput),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (busy) CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
|
||||||
|
color = tokens.accentOnFill,
|
||||||
|
) else Text("Connect wallet")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(tokens.accentSoft)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Check, contentDescription = null,
|
||||||
|
tint = tokens.accentText, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Wallet connected", color = tokens.accentText,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.weight(1f))
|
||||||
|
TextButton(onClick = { viewModel.disconnectWallet() }) {
|
||||||
|
Text("Disconnect", color = tokens.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// ── Advanced ──────────────────────────────────────────
|
||||||
|
TextButton(onClick = { showAdvanced = !showAdvanced }) {
|
||||||
|
Text(
|
||||||
|
if (showAdvanced) "Hide advanced" else "Advanced: relays & profile",
|
||||||
|
color = tokens.accentText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showAdvanced) {
|
||||||
|
SectionLabel("Relays")
|
||||||
|
relays.forEach { r ->
|
||||||
|
Text(
|
||||||
|
r,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = tokens.textSecondary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.setRelays(
|
||||||
|
listOf("wss://relay.damus.io", "wss://relay.primal.net", "wss://nos.lol"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { Text("Reset to defaults", color = tokens.textSecondary) }
|
||||||
|
|
||||||
|
SectionLabel("Publish profile")
|
||||||
|
OutlinedTextField(
|
||||||
|
value = nameInput,
|
||||||
|
onValueChange = { nameInput = it },
|
||||||
|
placeholder = { Text("Display name") },
|
||||||
|
singleLine = true,
|
||||||
|
colors = fieldColors(tokens),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = aboutInput,
|
||||||
|
onValueChange = { aboutInput = it },
|
||||||
|
placeholder = { Text("About (optional)") },
|
||||||
|
singleLine = true,
|
||||||
|
colors = fieldColors(tokens),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.publishProfile(nameInput, aboutInput) },
|
||||||
|
enabled = nameInput.isNotBlank() && busy.not(),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = tokens.accentFill,
|
||||||
|
contentColor = tokens.accentOnFill,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(SatsAmpGlass.rInput),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Publish to relays") }
|
||||||
|
}
|
||||||
|
|
||||||
|
message?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = tokens.accentText,
|
||||||
|
)
|
||||||
|
TextButton(onClick = { viewModel.consumeMessage() }) {
|
||||||
|
Text("Dismiss", color = tokens.textMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Close", color = tokens.accentText) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionLabel(text: String) {
|
||||||
|
Text(
|
||||||
|
text.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = LocalTokens.current.textMuted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun fieldColors(tokens: com.rada.ui.theme.UiTokens) =
|
||||||
|
OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = tokens.accentText,
|
||||||
|
unfocusedBorderColor = tokens.borderInteractive,
|
||||||
|
focusedTextColor = tokens.text,
|
||||||
|
unfocusedTextColor = tokens.text,
|
||||||
|
cursorColor = tokens.accentText,
|
||||||
|
focusedPlaceholderColor = tokens.textMuted,
|
||||||
|
unfocusedPlaceholderColor = tokens.textMuted,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
package com.rada.ui.decentralized
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.rada.nostr.NostrKeys
|
||||||
|
import com.rada.nostr.NostrService
|
||||||
|
import com.rada.payments.NwcProvider
|
||||||
|
import com.rada.payments.PaymentService
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class DecentralizedViewModel @Inject constructor(
|
||||||
|
private val keys: NostrKeys,
|
||||||
|
private val nostr: NostrService,
|
||||||
|
private val nwc: NwcProvider,
|
||||||
|
val paymentService: PaymentService,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** NIP-19 npub for display/share (never raw hex). */
|
||||||
|
val npub: kotlinx.coroutines.flow.StateFlow<String?> =
|
||||||
|
keys.pubkey
|
||||||
|
.map { hex -> hex?.let { com.rada.nostr.Nip19.npub(it) } }
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
kotlinx.coroutines.flow.SharingStarted.Eagerly,
|
||||||
|
keys.npub(),
|
||||||
|
)
|
||||||
|
val nostrEnabled = MutableStateFlow(nostr.enabled)
|
||||||
|
val walletConnected = MutableStateFlow(nwc.connected)
|
||||||
|
val message = MutableStateFlow<String?>(null)
|
||||||
|
val busy = MutableStateFlow(false)
|
||||||
|
val isSignerInstalled: Boolean get() = keys.isExternalSignerInstalled()
|
||||||
|
val signerPackage: String? get() = keys.signerPackage
|
||||||
|
|
||||||
|
private val _relays = MutableStateFlow(nostr.relaysConfig())
|
||||||
|
val relays: StateFlow<List<String>> = _relays.asStateFlow()
|
||||||
|
|
||||||
|
fun createIdentity() {
|
||||||
|
runCatching { keys.createIdentity() }
|
||||||
|
.onSuccess {
|
||||||
|
nostr.setEnabled(true)
|
||||||
|
nostrEnabled.value = true
|
||||||
|
message.value = "Nostr identity created ✅"
|
||||||
|
}
|
||||||
|
.onFailure { message.value = "Couldn't create identity: ${it.message}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importNsec(nsec: String) {
|
||||||
|
if (keys.importNsec(nsec)) {
|
||||||
|
nostr.setEnabled(true)
|
||||||
|
nostrEnabled.value = true
|
||||||
|
message.value = "Identity imported ✅"
|
||||||
|
} else {
|
||||||
|
message.value = "That doesn't look like a valid nsec."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importPubkey(npub: String) {
|
||||||
|
if (keys.importPubkey(npub)) {
|
||||||
|
nostr.setEnabled(true)
|
||||||
|
nostrEnabled.value = true
|
||||||
|
message.value = "Pubkey imported ✅ — relays connected for profile lookups"
|
||||||
|
} else {
|
||||||
|
message.value = "That doesn't look like a valid npub."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun amberLoginIntent(): Intent = keys.buildGetPubkeyIntent()
|
||||||
|
|
||||||
|
/** Handle the NIP-55 `get_public_key` activity result. */
|
||||||
|
fun onSignerResult(resultHex: String?, signerPkg: String?, rejected: Boolean): Boolean {
|
||||||
|
if (rejected) {
|
||||||
|
message.value = "Sign-in rejected in the signer app."
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (resultHex == null || !keys.importHexPubkey(resultHex)) {
|
||||||
|
message.value = "Signer didn't return a valid pubkey — try again."
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (signerPkg != null) keys.saveSignerPackage(signerPkg)
|
||||||
|
nostr.setEnabled(true)
|
||||||
|
nostrEnabled.value = true
|
||||||
|
message.value = "Connected via external signer ✅"
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSignerError() {
|
||||||
|
message.value = "Signer app failed to respond — try again."
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSignerNotFound() {
|
||||||
|
message.value = "No signer app found — install Amber (F-Droid / Zapstore / Play), then try again."
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signOut() {
|
||||||
|
keys.clearIdentity()
|
||||||
|
nostr.setEnabled(false)
|
||||||
|
nostrEnabled.value = false
|
||||||
|
message.value = "Signed out — identity erased from this device."
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publishProfile(name: String, about: String) {
|
||||||
|
busy.value = true
|
||||||
|
val ev = nostr.publishProfile(name, about, "")
|
||||||
|
busy.value = false
|
||||||
|
message.value = if (ev != null)
|
||||||
|
"Profile published to ${nostr.relaysConfig().size} relays"
|
||||||
|
else "Publish failed — check relays are reachable."
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRelays(list: List<String>) {
|
||||||
|
nostr.setRelays(list)
|
||||||
|
_relays.value = nostr.relaysConfig()
|
||||||
|
message.value = "Relays updated"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connectWallet(uri: String) {
|
||||||
|
if (nwc.connect(uri)) {
|
||||||
|
walletConnected.value = true
|
||||||
|
message.value = "Wallet connected via Nostr Wallet Connect ⚡"
|
||||||
|
} else {
|
||||||
|
message.value = "That pairing string isn't valid (nostr+walletconnect://…)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnectWallet() {
|
||||||
|
nwc.disconnect()
|
||||||
|
walletConnected.value = false
|
||||||
|
message.value = "Wallet disconnected"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeMessage() { message.value = null }
|
||||||
|
}
|
||||||
227
app/src/main/java/com/rada/ui/decentralized/TipSheet.kt
Normal file
227
app/src/main/java/com/rada/ui/decentralized/TipSheet.kt
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
package com.rada.ui.decentralized
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Bolt
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.rada.payments.hasValidLightningAddress
|
||||||
|
import com.rada.ui.theme.LocalTokens
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "⚡ Support" — the whole listener-facing payment surface. Presets +
|
||||||
|
* optional comment + one address field (remembered). Advanced wiring
|
||||||
|
* (NWC, relays) lives in the Decentralized sheet, not here.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TipSheet(
|
||||||
|
artistName: String,
|
||||||
|
trackTitle: String?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
initialAddress: String = "",
|
||||||
|
/** Wavlake artist id — a successful zap remembers its address for next time. */
|
||||||
|
artistId: String? = null,
|
||||||
|
viewModel: TipViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val tokens = LocalTokens.current
|
||||||
|
val status by viewModel.status.collectAsState()
|
||||||
|
val sending by viewModel.sending.collectAsState()
|
||||||
|
val hasWallet by viewModel.hasWallet.collectAsState()
|
||||||
|
val recents by viewModel.recentAddresses.collectAsState()
|
||||||
|
|
||||||
|
var amount by remember { mutableStateOf(100L) }
|
||||||
|
var customAmount by remember { mutableStateOf("") }
|
||||||
|
var address by remember(initialAddress) {
|
||||||
|
mutableStateOf(
|
||||||
|
initialAddress.ifBlank { recents.firstOrNull() ?: "" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var comment by remember { mutableStateOf("") }
|
||||||
|
val finalSats = customAmount.toLongOrNull()?.takeIf { it > 0 } ?: amount
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
containerColor = tokens.panel,
|
||||||
|
titleContentColor = tokens.text,
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
title = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("Support $artistName", fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
trackTitle?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
"playing · $it",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = tokens.textMuted,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
listOf(21L, 100L, 500L, 1000L).forEach { preset ->
|
||||||
|
FilterChip(
|
||||||
|
selected = customAmount.isBlank() && amount == preset,
|
||||||
|
onClick = { amount = preset; customAmount = "" },
|
||||||
|
label = { Text("$preset") },
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = tokens.accentSoft,
|
||||||
|
selectedLabelColor = tokens.accentText,
|
||||||
|
labelColor = tokens.textSecondary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = customAmount,
|
||||||
|
onValueChange = { customAmount = it.filter(Char::isDigit) },
|
||||||
|
placeholder = { Text("Custom amount in sats") },
|
||||||
|
singleLine = true,
|
||||||
|
suffix = { Text("sats", color = tokens.textMuted) },
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = tokens.accentText,
|
||||||
|
unfocusedBorderColor = tokens.borderInteractive,
|
||||||
|
focusedTextColor = tokens.text,
|
||||||
|
unfocusedTextColor = tokens.text,
|
||||||
|
cursorColor = tokens.accentText,
|
||||||
|
focusedPlaceholderColor = tokens.textMuted,
|
||||||
|
unfocusedPlaceholderColor = tokens.textMuted,
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = address,
|
||||||
|
onValueChange = { address = it },
|
||||||
|
placeholder = { Text("name@domain (Lightning address)") },
|
||||||
|
singleLine = true,
|
||||||
|
// Copy path needs no wallet: a valid address can always
|
||||||
|
// be taken to any wallet app. Shown only for valid
|
||||||
|
// addresses — never a dead or decorative button.
|
||||||
|
trailingIcon = {
|
||||||
|
if (hasValidLightningAddress(address)) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
androidx.compose.material3.IconButton(onClick = {
|
||||||
|
clipboard.setText(AnnotatedString(address.trim()))
|
||||||
|
// User-attested + syntactically valid: remembering
|
||||||
|
// here closes the wallet-less loop (paste once
|
||||||
|
// from the official page → bolt forever).
|
||||||
|
if (!artistId.isNullOrBlank()) {
|
||||||
|
viewModel.rememberArtistAddress(artistId, address)
|
||||||
|
}
|
||||||
|
viewModel.status.value =
|
||||||
|
"Address copied — paste it in any wallet app."
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ContentCopy,
|
||||||
|
contentDescription = "Copy Lightning address",
|
||||||
|
tint = tokens.accentText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = tokens.accentText,
|
||||||
|
unfocusedBorderColor = tokens.borderInteractive,
|
||||||
|
focusedTextColor = tokens.text,
|
||||||
|
unfocusedTextColor = tokens.text,
|
||||||
|
cursorColor = tokens.accentText,
|
||||||
|
focusedPlaceholderColor = tokens.textMuted,
|
||||||
|
unfocusedPlaceholderColor = tokens.textMuted,
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = comment,
|
||||||
|
onValueChange = { comment = it },
|
||||||
|
placeholder = { Text("Message (optional)") },
|
||||||
|
singleLine = true,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = tokens.accentText,
|
||||||
|
unfocusedBorderColor = tokens.borderInteractive,
|
||||||
|
focusedTextColor = tokens.text,
|
||||||
|
unfocusedTextColor = tokens.text,
|
||||||
|
cursorColor = tokens.accentText,
|
||||||
|
focusedPlaceholderColor = tokens.textMuted,
|
||||||
|
unfocusedPlaceholderColor = tokens.textMuted,
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
status?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall, color = tokens.accentText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.tip(address, finalSats, artistName, trackTitle, artistId) },
|
||||||
|
enabled = !sending && address.contains("@") && finalSats > 0,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = tokens.accentFill,
|
||||||
|
contentColor = tokens.accentOnFill,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
) {
|
||||||
|
if (sending) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
|
||||||
|
color = tokens.accentOnFill,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Send $finalSats sats")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
if (!hasWallet) {
|
||||||
|
Text(
|
||||||
|
"No wallet connected — copy the address\nabove and pay from any wallet app.",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = tokens.textMuted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
78
app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt
Normal file
78
app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
package com.rada.ui.decentralized
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import android.content.Context
|
||||||
|
import com.rada.data.wavlake.WavlakeRepository
|
||||||
|
import com.rada.payments.PaymentResult
|
||||||
|
import com.rada.payments.PaymentService
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class TipViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val paymentService: PaymentService,
|
||||||
|
private val wavlake: WavlakeRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences("tips", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
val status = MutableStateFlow<String?>(null)
|
||||||
|
val sending = MutableStateFlow(false)
|
||||||
|
val hasWallet = MutableStateFlow(paymentService.hasAnyWallet)
|
||||||
|
|
||||||
|
/** Recently used lightning addresses (most recent first, max 8). */
|
||||||
|
private val _recentAddresses = MutableStateFlow(
|
||||||
|
prefs.getString("recent", "")!!.split("\n").filter { it.contains("@") },
|
||||||
|
)
|
||||||
|
val recentAddresses: StateFlow<List<String>> = _recentAddresses.asStateFlow()
|
||||||
|
|
||||||
|
fun tip(
|
||||||
|
address: String,
|
||||||
|
sats: Long,
|
||||||
|
artistName: String,
|
||||||
|
trackTitle: String?,
|
||||||
|
artistId: String? = null,
|
||||||
|
) {
|
||||||
|
val clean = address.trim().removePrefix("lightning:")
|
||||||
|
if (!clean.contains("@")) {
|
||||||
|
status.value = "Enter a name@domain Lightning address."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sending.value = true
|
||||||
|
status.value = null
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val r = paymentService.tipArtist(clean, sats, artistName, trackTitle)) {
|
||||||
|
is PaymentResult.Paid -> {
|
||||||
|
status.value = "⚡ $sats sats sent to $artistName"
|
||||||
|
_recentAddresses.value =
|
||||||
|
(listOf(clean) + _recentAddresses.value.filter { it != clean })
|
||||||
|
.distinct().take(8)
|
||||||
|
prefs.edit()
|
||||||
|
.putString("recent", _recentAddresses.value.joinToString("\n"))
|
||||||
|
.apply()
|
||||||
|
// Remember validated addresses per artist: the bolt can
|
||||||
|
// appear (prefilled) from the next encounter on.
|
||||||
|
if (!artistId.isNullOrBlank()) {
|
||||||
|
wavlake.rememberAddress(artistId, clean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is PaymentResult.Failed -> status.value = r.userFacing
|
||||||
|
else -> status.value = "Invoice created."
|
||||||
|
}
|
||||||
|
sending.value = false
|
||||||
|
hasWallet.value = paymentService.hasAnyWallet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores a user-attested address so the bolt can appear next time. */
|
||||||
|
fun rememberArtistAddress(artistId: String, address: String) {
|
||||||
|
wavlake.rememberAddress(artistId, address)
|
||||||
|
}
|
||||||
|
}
|
||||||
1149
app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt
Normal file
1149
app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt
Normal file
File diff suppressed because it is too large
Load diff
304
app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt
Normal file
304
app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
package com.rada.ui.discover
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.rada.data.repository.PlaylistRepository
|
||||||
|
import com.rada.data.wavlake.WavlakeRepository
|
||||||
|
import com.rada.data.wavlake.WavlakeResult
|
||||||
|
import com.rada.data.wavlake.WavlakeSearchHit
|
||||||
|
import com.rada.data.wavlake.WavlakeStationSettings
|
||||||
|
import com.rada.domain.model.WavlakeArtist
|
||||||
|
import com.rada.domain.model.WavlakeTrack
|
||||||
|
import com.rada.payments.hasValidLightningAddress
|
||||||
|
import com.rada.player.PlaybackManager
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** Curated genre shortcuts — exact Wavlake catalog names only. */
|
||||||
|
val DISCOVER_GENRES = listOf("Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock")
|
||||||
|
|
||||||
|
sealed interface WavlakeSearchState {
|
||||||
|
data object Idle : WavlakeSearchState
|
||||||
|
data object Loading : WavlakeSearchState
|
||||||
|
data class Results(val hits: List<WavlakeSearchHit>) : WavlakeSearchState
|
||||||
|
data class Error(val message: String) : WavlakeSearchState
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class WavlakeViewModel @Inject constructor(
|
||||||
|
private val repository: WavlakeRepository,
|
||||||
|
private val playbackManager: PlaybackManager,
|
||||||
|
private val playlists: PlaylistRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _searchQuery = MutableStateFlow("")
|
||||||
|
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||||
|
|
||||||
|
private val _searchState = MutableStateFlow<WavlakeSearchState>(WavlakeSearchState.Idle)
|
||||||
|
val searchState: StateFlow<WavlakeSearchState> = _searchState.asStateFlow()
|
||||||
|
|
||||||
|
private val _rankings = MutableStateFlow<List<WavlakeTrack>>(emptyList())
|
||||||
|
val rankings: StateFlow<List<WavlakeTrack>> = _rankings.asStateFlow()
|
||||||
|
|
||||||
|
private val _rankingsGenre = MutableStateFlow<String?>(null)
|
||||||
|
val rankingsGenre: StateFlow<String?> = _rankingsGenre.asStateFlow()
|
||||||
|
|
||||||
|
private val _rankingsLoading = MutableStateFlow(false)
|
||||||
|
val rankingsLoading: StateFlow<Boolean> = _rankingsLoading.asStateFlow()
|
||||||
|
|
||||||
|
private val _rankingsError = MutableStateFlow<String?>(null)
|
||||||
|
val rankingsError: StateFlow<String?> = _rankingsError.asStateFlow()
|
||||||
|
|
||||||
|
/** Resolved Lightning Addresses by artist id — populated best-effort. */
|
||||||
|
private val _addresses = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
|
val addresses: StateFlow<Map<String, String>> = _addresses.asStateFlow()
|
||||||
|
|
||||||
|
private val _selectedArtistId = MutableStateFlow<String?>(null)
|
||||||
|
val selectedArtistId: StateFlow<String?> = _selectedArtistId.asStateFlow()
|
||||||
|
|
||||||
|
private val _artistDetail = MutableStateFlow<com.rada.data.wavlake.WavlakeArtistDetail?>(null)
|
||||||
|
val artistDetail: StateFlow<com.rada.data.wavlake.WavlakeArtistDetail?> =
|
||||||
|
_artistDetail.asStateFlow()
|
||||||
|
|
||||||
|
private val _artistLoading = MutableStateFlow(false)
|
||||||
|
val artistLoading: StateFlow<Boolean> = _artistLoading.asStateFlow()
|
||||||
|
|
||||||
|
private val _notice = MutableStateFlow<String?>(null)
|
||||||
|
val notice: StateFlow<String?> = _notice.asStateFlow()
|
||||||
|
|
||||||
|
val allPlaylists = playlists.getAllPlaylists()
|
||||||
|
|
||||||
|
init {
|
||||||
|
_searchQuery
|
||||||
|
.debounce(450)
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach { q ->
|
||||||
|
val query = q.trim()
|
||||||
|
if (query.length < 2) {
|
||||||
|
_searchState.value = WavlakeSearchState.Idle
|
||||||
|
return@onEach
|
||||||
|
}
|
||||||
|
_searchState.value = WavlakeSearchState.Loading
|
||||||
|
_searchState.value = when (val r = repository.search(query)) {
|
||||||
|
is WavlakeResult.Ok ->
|
||||||
|
if (r.value.isEmpty()) WavlakeSearchState.Error("No Wavlake results.")
|
||||||
|
else WavlakeSearchState.Results(r.value)
|
||||||
|
is WavlakeResult.Failed -> WavlakeSearchState.Error(r.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
refreshRankings(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search(query: String) {
|
||||||
|
_searchQuery.value = query
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshRankings(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 openArtist(artistId: String, name: String, npubHint: String? = null) {
|
||||||
|
_selectedArtistId.value = artistId
|
||||||
|
_artistDetail.value = null
|
||||||
|
viewModelScope.launch {
|
||||||
|
_artistLoading.value = true
|
||||||
|
when (val r = repository.artistDetail(artistId)) {
|
||||||
|
is WavlakeResult.Ok -> {
|
||||||
|
_artistDetail.value = r.value
|
||||||
|
val npub = r.value.tracks.firstNotNullOfOrNull { it.artistNpubHex }
|
||||||
|
?: npubHint
|
||||||
|
resolveArtistAddress(r.value.artist.id, npub)
|
||||||
|
resolveAddresses(r.value.tracks)
|
||||||
|
}
|
||||||
|
is WavlakeResult.Failed -> {
|
||||||
|
// Fallback shell so the name is still shown with the error.
|
||||||
|
_artistDetail.value = com.rada.data.wavlake.WavlakeArtistDetail(
|
||||||
|
artist = WavlakeArtist(id = artistId, name = name),
|
||||||
|
tracks = emptyList(),
|
||||||
|
)
|
||||||
|
_notice.value = r.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_artistLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeArtist() {
|
||||||
|
_selectedArtistId.value = null
|
||||||
|
_artistDetail.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort address resolution; bolts appear only on success (strict gate). */
|
||||||
|
private fun resolveAddresses(tracks: List<WavlakeTrack>) {
|
||||||
|
// Stored (user-confirmed) addresses first — instant, offline.
|
||||||
|
val known = tracks.map { it.artistId }.distinct()
|
||||||
|
.mapNotNull { id -> repository.getKnownAddress(id)?.let { id to it } }
|
||||||
|
.toMap()
|
||||||
|
if (known.isNotEmpty()) _addresses.value = _addresses.value + known
|
||||||
|
val npubs = tracks.mapNotNull { it.artistNpubHex }.distinct().take(12)
|
||||||
|
if (npubs.isEmpty()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val byArtist = tracks.mapNotNull { t ->
|
||||||
|
t.artistNpubHex?.let { npub ->
|
||||||
|
repository.resolveLightningAddress(t.artistId, npub)
|
||||||
|
?.let { addr -> t.artistId to addr }
|
||||||
|
}
|
||||||
|
}.toMap()
|
||||||
|
if (byArtist.isNotEmpty()) _addresses.value = _addresses.value + byArtist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveArtistAddress(artistId: String, npub: String?) {
|
||||||
|
repository.getKnownAddress(artistId)?.let { addr ->
|
||||||
|
_addresses.value = _addresses.value + (artistId to addr)
|
||||||
|
}
|
||||||
|
if (npub.isNullOrBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.resolveLightningAddress(artistId, npub)?.let { addr ->
|
||||||
|
_addresses.value = _addresses.value + (artistId to addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve and cache an artist's Lightning Address on-demand (for search results). */
|
||||||
|
fun resolveArtistAddress(artistId: String) {
|
||||||
|
val known = repository.getKnownAddress(artistId)
|
||||||
|
if (known != null) {
|
||||||
|
_addresses.value = _addresses.value + (artistId to known)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
val detail = repository.artistDetail(artistId)
|
||||||
|
if (detail is WavlakeResult.Ok) {
|
||||||
|
val npub = detail.value.tracks.firstNotNullOfOrNull { it.artistNpubHex }
|
||||||
|
if (npub != null) {
|
||||||
|
val addr = repository.resolveLightningAddress(artistId, npub)
|
||||||
|
if (addr != null) {
|
||||||
|
_addresses.value = _addresses.value + (artistId to addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strict gate: bolt iff a VALID Lightning Address is resolved. */
|
||||||
|
fun addressFor(artistId: String): String? =
|
||||||
|
_addresses.value[artistId]?.takeIf { hasValidLightningAddress(it) }
|
||||||
|
|
||||||
|
fun playTrack(track: WavlakeTrack) {
|
||||||
|
val url = track.audioUrl ?: return
|
||||||
|
playbackManager.playLocalQueue(
|
||||||
|
listOf(PlaybackManager.LocalTrack(null, url, track.title, track.artistName)),
|
||||||
|
startIndex = 0,
|
||||||
|
shuffle = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveTrack(track: WavlakeTrack) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_notice.value = when (val r = repository.importTrack(track)) {
|
||||||
|
is WavlakeRepository.ImportOutcome.Imported -> "Saved “${track.title}” to Library"
|
||||||
|
is WavlakeRepository.ImportOutcome.AlreadyInLibrary -> "Already in your Library"
|
||||||
|
is WavlakeRepository.ImportOutcome.Unplayable -> r.reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addTrackToPlaylists(track: WavlakeTrack, playlistIds: List<Long>) {
|
||||||
|
if (playlistIds.isEmpty()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val outcome = repository.importTrack(track)
|
||||||
|
val songId = when (outcome) {
|
||||||
|
is WavlakeRepository.ImportOutcome.Imported -> outcome.songId
|
||||||
|
is WavlakeRepository.ImportOutcome.AlreadyInLibrary -> outcome.songId
|
||||||
|
is WavlakeRepository.ImportOutcome.Unplayable -> {
|
||||||
|
_notice.value = outcome.reason
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
playlistIds.forEach { playlists.addSongToPlaylist(it, songId) }
|
||||||
|
_notice.value = if (playlistIds.size == 1) "Added to playlist"
|
||||||
|
else "Added to ${playlistIds.size} playlists"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun playSearchTrack(trackId: String, artistId: String, title: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val r = repository.trackDetail(trackId)) {
|
||||||
|
is WavlakeResult.Ok -> playTrack(r.value)
|
||||||
|
is WavlakeResult.Failed ->
|
||||||
|
repository.matchArtistTrack(artistId, title)?.let { playTrack(it) }
|
||||||
|
?: run { _notice.value = r.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSearchTrack(trackId: String, artistId: String, title: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val r = repository.trackDetail(trackId)) {
|
||||||
|
is WavlakeResult.Ok -> saveTrack(r.value)
|
||||||
|
is WavlakeResult.Failed ->
|
||||||
|
repository.matchArtistTrack(artistId, title)?.let { saveTrack(it) }
|
||||||
|
?: run { _notice.value = r.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun featuredFor(stationId: String, genre: String): List<WavlakeTrack> =
|
||||||
|
repository.featuredForStation(stationId, genre)
|
||||||
|
|
||||||
|
/** Resolve a search hit to a full track (for official zap eligibility). */
|
||||||
|
fun resolveForZap(trackId: String, artistId: String, title: String, onResult: (WavlakeTrack?) -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val full = when (val r = repository.trackDetail(trackId)) {
|
||||||
|
is WavlakeResult.Ok -> r.value
|
||||||
|
is WavlakeResult.Failed -> repository.matchArtistTrack(artistId, title)
|
||||||
|
}
|
||||||
|
if (full != null && full.isZapPayable) onResult(full)
|
||||||
|
else {
|
||||||
|
_notice.value = "This track can't be zapped yet."
|
||||||
|
onResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeNotice() {
|
||||||
|
_notice.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun settings(): WavlakeStationSettings = repository.settings()
|
||||||
|
|
||||||
|
fun saveSettings(settings: WavlakeStationSettings) = repository.saveSettings(settings)
|
||||||
|
|
||||||
|
fun retrySearch() {
|
||||||
|
val q = _searchQuery.value
|
||||||
|
_searchQuery.value = "$q "
|
||||||
|
_searchQuery.value = q
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
package com.rada.ui.discover
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.rada.domain.model.WavlakeTrack
|
||||||
|
import com.rada.payments.ZapService
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Official Wavlake track-zap sheet state machine. Amount → invoice →
|
||||||
|
* pay (wallet) or copy-out (no wallet). Success surfaces only after the
|
||||||
|
* wallet confirms payment.
|
||||||
|
*/
|
||||||
|
sealed interface TrackZapState {
|
||||||
|
data object Editing : TrackZapState
|
||||||
|
data object Sending : TrackZapState
|
||||||
|
data class InvoiceReady(val bolt11: String, val sats: Long) : TrackZapState
|
||||||
|
data object Sent : TrackZapState
|
||||||
|
data class Failed(val reason: String) : TrackZapState
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class WavlakeZapViewModel @Inject constructor(
|
||||||
|
private val zaps: ZapService,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<TrackZapState>(TrackZapState.Editing)
|
||||||
|
val state: StateFlow<TrackZapState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
_state.value = TrackZapState.Editing
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mint an invoice without paying (no wallet needed) for copy-out. */
|
||||||
|
fun fetchInvoice(track: WavlakeTrack, sats: Long, comment: String) {
|
||||||
|
val albumId = track.albumId ?: return
|
||||||
|
_state.value = TrackZapState.Sending
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = try {
|
||||||
|
when (val r = zaps.wavlakeTrackInvoice(track.id, albumId, sats, comment)) {
|
||||||
|
is ZapService.WavlakeZapOutcome.Invoice ->
|
||||||
|
TrackZapState.InvoiceReady(r.bolt11, r.sats)
|
||||||
|
is ZapService.WavlakeZapOutcome.Sent -> TrackZapState.Sent
|
||||||
|
is ZapService.WavlakeZapOutcome.Failed ->
|
||||||
|
TrackZapState.Failed(r.reason)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TrackZapState.Failed(e.message ?: "Invoice failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full flow: invoice, pay via the connected wallet, await confirmation. */
|
||||||
|
fun send(track: WavlakeTrack, sats: Long, comment: String) {
|
||||||
|
val albumId = track.albumId ?: return
|
||||||
|
_state.value = TrackZapState.Sending
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = try {
|
||||||
|
when (val r = zaps.zapWavlakeTrack(track.id, albumId, sats, comment)) {
|
||||||
|
is ZapService.WavlakeZapOutcome.Sent -> TrackZapState.Sent
|
||||||
|
is ZapService.WavlakeZapOutcome.Invoice ->
|
||||||
|
TrackZapState.InvoiceReady(r.bolt11, r.sats)
|
||||||
|
is ZapService.WavlakeZapOutcome.Failed ->
|
||||||
|
TrackZapState.Failed(r.reason)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TrackZapState.Failed(e.message ?: "Zap failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,9 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.ArrowDownward
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
import androidx.compose.material.icons.filled.ArrowUpward
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
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.Delete
|
||||||
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
|
||||||
|
|
@ -33,6 +36,8 @@ import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Shuffle
|
import androidx.compose.material.icons.filled.Shuffle
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
|
@ -45,6 +50,7 @@ import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.ScrollableTabRow
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.TabRowDefaults
|
import androidx.compose.material3.TabRowDefaults
|
||||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||||
|
|
@ -78,6 +84,10 @@ import com.rada.data.local.entity.SavedSongEntity
|
||||||
import com.rada.data.repository.SortOrder
|
import com.rada.data.repository.SortOrder
|
||||||
import com.rada.ui.theme.AppearanceDialog
|
import com.rada.ui.theme.AppearanceDialog
|
||||||
import com.rada.ui.components.AppHomeButton
|
import com.rada.ui.components.AppHomeButton
|
||||||
|
import com.rada.ui.components.EqualizerBars
|
||||||
|
import com.rada.ui.theme.LocalReduceMotion
|
||||||
|
import com.rada.ui.theme.pressDip
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import com.rada.ui.theme.LocalTokens
|
import com.rada.ui.theme.LocalTokens
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -97,6 +107,7 @@ fun LibraryScreen(
|
||||||
var showImportMenu by remember { mutableStateOf(false) }
|
var showImportMenu 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) }
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
|
@ -196,6 +207,19 @@ fun LibraryScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Multi-select: combine several folders, shuffle the union.
|
||||||
|
if (tabLabels.size > 1) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
if (uiState.isMultiSelect) viewModel.exitMultiSelect()
|
||||||
|
else viewModel.enterMultiSelect()
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Checklist,
|
||||||
|
contentDescription = if (uiState.isMultiSelect) "Exit playlist selection" else "Select multiple playlists",
|
||||||
|
tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
IconButton(onClick = { showAppearance = true }) {
|
IconButton(onClick = { showAppearance = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Settings,
|
Icons.Default.Settings,
|
||||||
|
|
@ -211,12 +235,54 @@ fun LibraryScreen(
|
||||||
},
|
},
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
|
bottomBar = {
|
||||||
|
if (uiState.isMultiSelect) {
|
||||||
|
MultiShuffleBar(
|
||||||
|
songCount = uiState.songs.size,
|
||||||
|
listCount = uiState.multiSelectedIds.size,
|
||||||
|
onShuffle = { viewModel.shuffleMulti() },
|
||||||
|
onExit = { viewModel.exitMultiSelect() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding),
|
.padding(padding),
|
||||||
) {
|
) {
|
||||||
|
// View header: what you're looking at + live count (Apple-style
|
||||||
|
// display type; doubles as orientation in multi-select/search).
|
||||||
|
val selectedPlaylist =
|
||||||
|
filteredPlaylists.firstOrNull { it.id == uiState.selectedPlaylistId }
|
||||||
|
val headerTitle = when {
|
||||||
|
uiState.isMultiSelect ->
|
||||||
|
if (uiState.multiSelectedIds.isEmpty()) "Select lists"
|
||||||
|
else "${uiState.multiSelectedIds.size} lists"
|
||||||
|
selectedPlaylist != null -> selectedPlaylist.name
|
||||||
|
else -> "All Songs"
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 4.dp, bottom = 2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = headerTitle,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = LocalTokens.current.text,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${uiState.songs.size} songs",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = LocalTokens.current.textSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (tabLabels.size > 1) {
|
if (tabLabels.size > 1) {
|
||||||
ScrollableTabRow(
|
ScrollableTabRow(
|
||||||
selectedTabIndex = selectedTabIndex,
|
selectedTabIndex = selectedTabIndex,
|
||||||
|
|
@ -242,6 +308,13 @@ fun LibraryScreen(
|
||||||
onDispose { tabLpHandler.removeCallbacksAndMessages(null) }
|
onDispose { tabLpHandler.removeCallbacksAndMessages(null) }
|
||||||
}
|
}
|
||||||
tabLabels.forEachIndexed { index, label ->
|
tabLabels.forEachIndexed { index, label ->
|
||||||
|
val tabPlaylistId =
|
||||||
|
if (index == 0) null else filteredPlaylists[index - 1].id
|
||||||
|
val tabSelected = if (uiState.isMultiSelect) {
|
||||||
|
index > 0 && tabPlaylistId in uiState.multiSelectedIds
|
||||||
|
} else {
|
||||||
|
selectedTabIndex == index
|
||||||
|
}
|
||||||
Tab(
|
Tab(
|
||||||
modifier = if (index == 0) Modifier else Modifier.pointerInput(index) {
|
modifier = if (index == 0) Modifier else Modifier.pointerInput(index) {
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
|
|
@ -266,20 +339,33 @@ fun LibraryScreen(
|
||||||
tabLpHandler.removeCallbacks(timer)
|
tabLpHandler.removeCallbacks(timer)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selected = selectedTabIndex == index,
|
selected = tabSelected,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (index == 0) {
|
if (uiState.isMultiSelect) {
|
||||||
|
if (index == 0) viewModel.exitMultiSelect()
|
||||||
|
else viewModel.togglePlaylistMulti(filteredPlaylists[index - 1].id)
|
||||||
|
} else if (index == 0) {
|
||||||
viewModel.selectPlaylist(null)
|
viewModel.selectPlaylist(null)
|
||||||
} else {
|
} else {
|
||||||
viewModel.selectPlaylist(filteredPlaylists[index - 1].id)
|
viewModel.selectPlaylist(filteredPlaylists[index - 1].id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
icon = if (tabSelected && uiState.isMultiSelect) {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = LocalTokens.current.accentFill,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = if (selectedTabIndex == index) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
|
color = if (tabSelected) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -358,8 +444,11 @@ fun LibraryScreen(
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = if (uiState.selectedPlaylistId != null) "Playlist is empty"
|
text = when {
|
||||||
else "No saved songs yet — tap + to import songs from your device",
|
uiState.isMultiSelect -> "Tap playlist tabs above to combine them"
|
||||||
|
uiState.selectedPlaylistId != null -> "Playlist is empty"
|
||||||
|
else -> "No saved songs yet — tap + to import songs from your device"
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = LocalTokens.current.textSecondary,
|
color = LocalTokens.current.textSecondary,
|
||||||
)
|
)
|
||||||
|
|
@ -375,6 +464,7 @@ fun LibraryScreen(
|
||||||
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) },
|
||||||
|
onAddToPlaylist = { songForPlaylist = song },
|
||||||
onDelete = { viewModel.deleteSong(song) },
|
onDelete = { viewModel.deleteSong(song) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -390,8 +480,7 @@ fun LibraryScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
playlistToDelete?.let { playlist ->
|
playlistToDelete?.let { playlist ->
|
||||||
AlertDialog(
|
AlertDialog( onDismissRequest = { playlistToDelete = null },
|
||||||
onDismissRequest = { playlistToDelete = null },
|
|
||||||
title = { Text("Delete \"${playlist.name}\"?") },
|
title = { Text("Delete \"${playlist.name}\"?") },
|
||||||
text = { Text("You can delete just the folder group — the songs stay in All Songs — or delete the songs and their files too.") },
|
text = { Text("You can delete just the folder group — the songs stay in All Songs — or delete the songs and their files too.") },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
|
|
@ -417,6 +506,205 @@ fun LibraryScreen(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
songForPlaylist?.let { song ->
|
||||||
|
AddSongToPlaylistDialog(
|
||||||
|
songTitle = song.title,
|
||||||
|
playlists = allPlaylists,
|
||||||
|
onDismiss = { songForPlaylist = null },
|
||||||
|
onPlaylistsConfirmed = { playlistIds ->
|
||||||
|
viewModel.addSongToPlaylists(song.id, playlistIds)
|
||||||
|
songForPlaylist = null
|
||||||
|
},
|
||||||
|
onCreatePlaylist = { name ->
|
||||||
|
viewModel.createPlaylistAndAdd(song.id, name)
|
||||||
|
songForPlaylist = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddSongToPlaylistDialog(
|
||||||
|
songTitle: String,
|
||||||
|
playlists: List<PlaylistEntity>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onPlaylistsConfirmed: (List<Long>) -> Unit,
|
||||||
|
onCreatePlaylist: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var showNewInput by remember { mutableStateOf(false) }
|
||||||
|
var newPlaylistName by remember { mutableStateOf("") }
|
||||||
|
var selectedIds by remember { mutableStateOf(setOf<Long>()) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.PlaylistAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = LocalTokens.current.accentFill,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text("Add to playlists") },
|
||||||
|
text = {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = songTitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = LocalTokens.current.textSecondary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
if (playlists.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "No playlists yet. Create one below.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = LocalTokens.current.textSecondary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
) {
|
||||||
|
items(playlists, key = { it.id }) { playlist ->
|
||||||
|
val checked = playlist.id in selectedIds
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
selectedIds =
|
||||||
|
if (checked) selectedIds - playlist.id
|
||||||
|
else selectedIds + playlist.id
|
||||||
|
}
|
||||||
|
.padding(vertical = 6.dp, horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = { isChecked ->
|
||||||
|
selectedIds =
|
||||||
|
if (isChecked) selectedIds + playlist.id
|
||||||
|
else selectedIds - playlist.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = playlist.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = LocalTokens.current.text,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
if (showNewInput) {
|
||||||
|
TextField(
|
||||||
|
value = newPlaylistName,
|
||||||
|
onValueChange = { newPlaylistName = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Playlist name") },
|
||||||
|
singleLine = true,
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = LocalTokens.current.panel,
|
||||||
|
unfocusedContainerColor = LocalTokens.current.panel,
|
||||||
|
focusedIndicatorColor = LocalTokens.current.accentFill.copy(alpha = 0.5f),
|
||||||
|
unfocusedIndicatorColor = LocalTokens.current.border,
|
||||||
|
focusedTextColor = LocalTokens.current.text,
|
||||||
|
unfocusedTextColor = LocalTokens.current.text,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = { onCreatePlaylist(newPlaylistName) },
|
||||||
|
enabled = newPlaylistName.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text("Create & add")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = { showNewInput = true }) {
|
||||||
|
Text("New playlist…")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onPlaylistsConfirmed(selectedIds.toList()) },
|
||||||
|
enabled = selectedIds.isNotEmpty(),
|
||||||
|
) {
|
||||||
|
Text(if (selectedIds.size == 1) "Add" else "Add to ${selectedIds.size}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MultiShuffleBar(
|
||||||
|
songCount: Int,
|
||||||
|
listCount: Int,
|
||||||
|
onShuffle: () -> Unit,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = LocalTokens.current.panel,
|
||||||
|
contentColor = LocalTokens.current.text,
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onExit) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Exit playlist selection",
|
||||||
|
tint = LocalTokens.current.textSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = if (listCount == 0) "Select playlists" else "$songCount songs",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (listCount == 0) "Tap tabs above to combine them"
|
||||||
|
else if (listCount == 1) "1 list"
|
||||||
|
else "$listCount lists",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = LocalTokens.current.textSecondary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onShuffle,
|
||||||
|
enabled = songCount > 0,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Shuffle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Shuffle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -460,21 +748,32 @@ private fun SongListItem(
|
||||||
isPlaying: Boolean,
|
isPlaying: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onToggleFavorite: () -> Unit,
|
onToggleFavorite: () -> Unit,
|
||||||
|
onAddToPlaylist: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val interaction = remember { MutableInteractionSource() }
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick)
|
.pressDip(interaction, LocalReduceMotion.current)
|
||||||
|
.clickable(interactionSource = interaction, indication = androidx.compose.foundation.LocalIndication.current, onClick = onClick)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (isPlaying) {
|
||||||
Icons.Default.MusicNote,
|
EqualizerBars(
|
||||||
contentDescription = null,
|
active = true,
|
||||||
tint = if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
|
color = LocalTokens.current.accentFill,
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.MusicNote,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = LocalTokens.current.textSecondary,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -508,6 +807,13 @@ private fun SongListItem(
|
||||||
tint = if (song.isFavorite) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
|
tint = if (song.isFavorite) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
IconButton(onClick = onAddToPlaylist) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.PlaylistAdd,
|
||||||
|
contentDescription = "Add “${song.title}” to playlist",
|
||||||
|
tint = LocalTokens.current.textSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
IconButton(onClick = onDelete) {
|
IconButton(onClick = onDelete) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Delete,
|
Icons.Default.Delete,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.rada.data.local.entity.PlaylistEntity
|
import com.rada.data.local.entity.PlaylistEntity
|
||||||
import com.rada.data.local.entity.SavedSongEntity
|
import com.rada.data.local.entity.SavedSongEntity
|
||||||
|
import com.rada.data.repository.DedupeLibrary
|
||||||
import com.rada.data.repository.LibraryRepository
|
import com.rada.data.repository.LibraryRepository
|
||||||
import com.rada.data.repository.PlaylistRepository
|
import com.rada.data.repository.PlaylistRepository
|
||||||
import com.rada.data.repository.SortOrder
|
import com.rada.data.repository.SortOrder
|
||||||
|
|
@ -17,11 +18,11 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -30,6 +31,7 @@ class LibraryViewModel @Inject constructor(
|
||||||
private val playlistRepository: PlaylistRepository,
|
private val playlistRepository: PlaylistRepository,
|
||||||
private val playbackManager: PlaybackManager,
|
private val playbackManager: PlaybackManager,
|
||||||
private val songImporter: SongImporter,
|
private val songImporter: SongImporter,
|
||||||
|
private val dedupeLibrary: DedupeLibrary,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(LibraryUiState())
|
private val _uiState = MutableStateFlow(LibraryUiState())
|
||||||
|
|
@ -73,6 +75,8 @@ class LibraryViewModel @Inject constructor(
|
||||||
private val sortOrder = MutableStateFlow(SortOrder.DATE_SAVED)
|
private val sortOrder = MutableStateFlow(SortOrder.DATE_SAVED)
|
||||||
private val sortAscending = MutableStateFlow(true)
|
private val sortAscending = MutableStateFlow(true)
|
||||||
private val selectedPlaylistId = MutableStateFlow<Long?>(null)
|
private val selectedPlaylistId = MutableStateFlow<Long?>(null)
|
||||||
|
private val multiSelect = MutableStateFlow(false)
|
||||||
|
private val multiSelectedIds = MutableStateFlow<Set<Long>>(emptySet())
|
||||||
|
|
||||||
val allPlaylists: StateFlow<List<PlaylistEntity>> = playlistRepository.getAllPlaylists()
|
val allPlaylists: StateFlow<List<PlaylistEntity>> = playlistRepository.getAllPlaylists()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
|
@ -88,34 +92,61 @@ class LibraryViewModel @Inject constructor(
|
||||||
// flatMapLatest cancels the previous songs flow when the selection
|
// flatMapLatest cancels the previous songs flow when the selection
|
||||||
// changes, so picking a playlist immediately switches to the
|
// changes, so picking a playlist immediately switches to the
|
||||||
// playlist-filtered query instead of staying on the full library.
|
// playlist-filtered query instead of staying on the full library.
|
||||||
val songsFlow = combine(searchQuery, sortOrder, selectedPlaylistId) { query, sort, playlistId ->
|
val songsFlow = combine(
|
||||||
Triple(query, sort, playlistId)
|
searchQuery, sortOrder, selectedPlaylistId, multiSelect, multiSelectedIds
|
||||||
}.flatMapLatest { (query, sort, playlistId) ->
|
) { query, sort, playlistId, multi, ids ->
|
||||||
|
SongQuery(query, sort, playlistId, multi, ids)
|
||||||
|
}.flatMapLatest { q ->
|
||||||
when {
|
when {
|
||||||
|
// Multi-select wins over everything: union of picked lists
|
||||||
|
// (empty until the first list is tapped).
|
||||||
|
q.multi ->
|
||||||
|
if (q.ids.isNotEmpty()) playlistRepository.getSongsInPlaylists(q.ids.toList())
|
||||||
|
else flowOf(emptyList())
|
||||||
// A playlist selection always wins: query strictly by playlist_id.
|
// A playlist selection always wins: query strictly by playlist_id.
|
||||||
playlistId != null -> playlistRepository.getSongsInPlaylist(playlistId)
|
q.playlistId != null -> playlistRepository.getSongsInPlaylist(q.playlistId)
|
||||||
query.isEmpty() -> libraryRepository.getAll(sort)
|
q.query.isEmpty() -> libraryRepository.getAll(q.sort)
|
||||||
else -> libraryRepository.search(query)
|
else -> libraryRepository.search(q.query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sort direction and in-folder sorting are applied here so every view
|
// Sort direction and in-folder sorting are applied here so every view
|
||||||
// (All Songs, folder tabs, search results) obeys the visible sort chips.
|
// (All Songs, folder tabs, search results, multi-select union) obeys
|
||||||
|
// the visible sort chips.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
combine(songsFlow, sortOrder, sortAscending) { songs, sort, ascending ->
|
combine(songsFlow, sortOrder, sortAscending, multiSelect, multiSelectedIds) { songs, sort, ascending, multi, ids ->
|
||||||
Triple(sortSongs(songs, sort, ascending), sort, ascending)
|
SongListView(songs, sort, ascending, multi, ids)
|
||||||
}.collect { (sortedSongs, sort, ascending) ->
|
}.collect { view ->
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
songs = sortedSongs,
|
songs = sortSongs(view.songs, view.sort, view.ascending),
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
sortOrder = sort,
|
sortOrder = view.sort,
|
||||||
sortAscending = ascending,
|
sortAscending = view.ascending,
|
||||||
|
isMultiSelect = view.multi,
|
||||||
|
multiSelectedIds = view.ids,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
playbackManager.currentLocalTrackIdFlow.collect { id ->
|
||||||
|
if (id != null) _uiState.update { it.copy(currentPlayingId = id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Collapse duplicate saves (same artist+title) once per app session.
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = runCatching { dedupeLibrary.run() }.getOrNull()
|
||||||
|
if (result != null && result.removed > 0) {
|
||||||
|
_importMessage.value =
|
||||||
|
"Removed ${result.removed} duplicate song" +
|
||||||
|
(if (result.removed == 1) "" else "s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun sortSongs(
|
private fun sortSongs(
|
||||||
songs: List<SavedSongEntity>,
|
songs: List<SavedSongEntity>,
|
||||||
order: SortOrder,
|
order: SortOrder,
|
||||||
|
|
@ -138,58 +169,30 @@ class LibraryViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playSong(song: SavedSongEntity) {
|
fun playSong(song: SavedSongEntity) {
|
||||||
playbackManager.playLocalFile(
|
// Queue the visible list from the tapped song so transport next/prev
|
||||||
filePath = song.filePath,
|
// and end-of-track advance through the library natively (ExoPlayer).
|
||||||
title = song.title,
|
val queue = _uiState.value.songs
|
||||||
artist = song.artist,
|
val startIdx = queue.indexOfFirst { it.id == song.id }.coerceAtLeast(0)
|
||||||
)
|
playQueue(queue, startIdx, shuffle = false)
|
||||||
_uiState.update { it.copy(currentPlayingId = song.id, shuffleQueue = emptyList(), shuffleIndex = -1) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun shufflePlay() {
|
fun shufflePlay() {
|
||||||
val songs = _uiState.value.songs
|
val queue = _uiState.value.songs
|
||||||
if (songs.isEmpty()) return
|
if (queue.isEmpty()) return
|
||||||
val shuffled = songs.shuffled(Random)
|
playQueue(queue, startIndex = 0, shuffle = true)
|
||||||
_uiState.update { it.copy(shuffleQueue = shuffled, shuffleIndex = 0) }
|
|
||||||
val first = shuffled.first()
|
|
||||||
playbackManager.playLocalFile(
|
|
||||||
filePath = first.filePath,
|
|
||||||
title = first.title,
|
|
||||||
artist = first.artist,
|
|
||||||
)
|
|
||||||
_uiState.update { it.copy(currentPlayingId = first.id) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playNextInShuffle(): Boolean {
|
private fun playQueue(queue: List<SavedSongEntity>, startIndex: Int, shuffle: Boolean) {
|
||||||
val queue = _uiState.value.shuffleQueue
|
playbackManager.playLocalQueue(
|
||||||
val idx = _uiState.value.shuffleIndex
|
tracks = queue.map {
|
||||||
if (queue.isEmpty() || idx < 0) return false
|
PlaybackManager.LocalTrack(it.id, it.filePath, it.title, it.artist)
|
||||||
val nextIdx = idx + 1
|
},
|
||||||
if (nextIdx >= queue.size) return false
|
startIndex = startIndex,
|
||||||
val next = queue[nextIdx]
|
shuffle = shuffle,
|
||||||
playbackManager.playLocalFile(
|
|
||||||
filePath = next.filePath,
|
|
||||||
title = next.title,
|
|
||||||
artist = next.artist,
|
|
||||||
)
|
)
|
||||||
_uiState.update { it.copy(currentPlayingId = next.id, shuffleIndex = nextIdx) }
|
_uiState.update { it.copy(currentPlayingId = queue[startIndex].id) }
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playPreviousInShuffle(): Boolean {
|
|
||||||
val queue = _uiState.value.shuffleQueue
|
|
||||||
val idx = _uiState.value.shuffleIndex
|
|
||||||
if (queue.isEmpty() || idx <= 0) return false
|
|
||||||
val prevIdx = idx - 1
|
|
||||||
val prev = queue[prevIdx]
|
|
||||||
playbackManager.playLocalFile(
|
|
||||||
filePath = prev.filePath,
|
|
||||||
title = prev.title,
|
|
||||||
artist = prev.artist,
|
|
||||||
)
|
|
||||||
_uiState.update { it.copy(currentPlayingId = prev.id, shuffleIndex = prevIdx) }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleFavorite(songId: Long) {
|
fun toggleFavorite(songId: Long) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -197,8 +200,7 @@ class LibraryViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteSong(song: SavedSongEntity) {
|
fun deleteSong(song: SavedSongEntity) { viewModelScope.launch {
|
||||||
viewModelScope.launch {
|
|
||||||
libraryRepository.delete(song)
|
libraryRepository.delete(song)
|
||||||
try {
|
try {
|
||||||
val file = java.io.File(song.filePath)
|
val file = java.io.File(song.filePath)
|
||||||
|
|
@ -207,6 +209,25 @@ class LibraryViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun addSongToPlaylists(songId: Long, playlistIds: List<Long>) {
|
||||||
|
if (playlistIds.isEmpty()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
playlistIds.forEach { playlistRepository.addSongToPlaylist(it, songId) }
|
||||||
|
_importMessage.value = if (playlistIds.size == 1) "Added to playlist"
|
||||||
|
else "Added to ${playlistIds.size} playlists"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createPlaylistAndAdd(songId: Long, name: String) {
|
||||||
|
val trimmed = name.trim()
|
||||||
|
if (trimmed.isEmpty()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val playlistId = playlistRepository.createPlaylist(trimmed)
|
||||||
|
playlistRepository.addSongToPlaylist(playlistId, songId)
|
||||||
|
_importMessage.value = "Created \"$trimmed\" and added song"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun search(query: String) {
|
fun search(query: String) {
|
||||||
searchQuery.value = query
|
searchQuery.value = query
|
||||||
}
|
}
|
||||||
|
|
@ -234,6 +255,38 @@ class LibraryViewModel @Inject constructor(
|
||||||
_uiState.update { it.copy(selectedPlaylistId = playlistId) }
|
_uiState.update { it.copy(selectedPlaylistId = playlistId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Enters multi-select (union view takes over; single selection clears). */
|
||||||
|
fun enterMultiSelect() {
|
||||||
|
selectedPlaylistId.value = null
|
||||||
|
multiSelectedIds.value = emptySet()
|
||||||
|
multiSelect.value = true
|
||||||
|
_uiState.update { it.copy(selectedPlaylistId = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leaves multi-select and returns to All Songs. */
|
||||||
|
fun exitMultiSelect() {
|
||||||
|
multiSelect.value = false
|
||||||
|
multiSelectedIds.value = emptySet()
|
||||||
|
selectedPlaylistId.value = null
|
||||||
|
_uiState.update { it.copy(selectedPlaylistId = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggles one playlist's membership in the multi-select union. */
|
||||||
|
fun togglePlaylistMulti(playlistId: Long) {
|
||||||
|
if (!multiSelect.value) return
|
||||||
|
multiSelectedIds.value =
|
||||||
|
if (playlistId in multiSelectedIds.value) multiSelectedIds.value - playlistId
|
||||||
|
else multiSelectedIds.value + playlistId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shuffles the current multi-select union, then leaves select mode. */
|
||||||
|
fun shuffleMulti() {
|
||||||
|
val queue = _uiState.value.songs
|
||||||
|
if (queue.isEmpty()) return
|
||||||
|
playQueue(queue, startIndex = 0, shuffle = true)
|
||||||
|
exitMultiSelect()
|
||||||
|
}
|
||||||
|
|
||||||
fun deletePlaylist(playlist: PlaylistEntity, deleteSongs: Boolean = false) {
|
fun deletePlaylist(playlist: PlaylistEntity, deleteSongs: Boolean = false) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (deleteSongs) {
|
if (deleteSongs) {
|
||||||
|
|
@ -264,9 +317,28 @@ data class LibraryUiState(
|
||||||
val selectedPlaylistId: Long? = null,
|
val selectedPlaylistId: Long? = null,
|
||||||
val sortOrder: SortOrder = SortOrder.DATE_SAVED,
|
val sortOrder: SortOrder = SortOrder.DATE_SAVED,
|
||||||
val sortAscending: Boolean = true,
|
val sortAscending: Boolean = true,
|
||||||
val shuffleQueue: List<SavedSongEntity> = emptyList(),
|
|
||||||
val shuffleIndex: Int = -1,
|
|
||||||
val isImporting: Boolean = false,
|
val isImporting: Boolean = false,
|
||||||
|
/** Multi-select union mode: tabs toggle membership, list shows the union. */
|
||||||
|
val isMultiSelect: Boolean = false,
|
||||||
|
val multiSelectedIds: Set<Long> = emptySet(),
|
||||||
) {
|
) {
|
||||||
val isShuffleMode: Boolean get() = shuffleQueue.isNotEmpty() && shuffleIndex >= 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One snapshot of the inputs driving the visible song list. */
|
||||||
|
private data class SongQuery(
|
||||||
|
val query: String,
|
||||||
|
val sort: SortOrder,
|
||||||
|
val playlistId: Long?,
|
||||||
|
val multi: Boolean,
|
||||||
|
val ids: Set<Long>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Songs + the view flags they were collected under. */
|
||||||
|
private data class SongListView(
|
||||||
|
val songs: List<SavedSongEntity>,
|
||||||
|
val sort: SortOrder,
|
||||||
|
val ascending: Boolean,
|
||||||
|
val multi: Boolean,
|
||||||
|
val ids: Set<Long>,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import com.rada.ui.streams.StreamsScreen
|
||||||
fun NavGraph(navController: NavHostController) {
|
fun NavGraph(navController: NavHostController) {
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
val showNowPlayingBar = currentRoute != Screen.Player.route
|
val showNowPlayingBar = currentRoute != Screen.Player.route && currentRoute != Screen.Tabs.route
|
||||||
val reduce = LocalReduceMotion.current
|
val reduce = LocalReduceMotion.current
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|
@ -38,6 +38,25 @@ fun NavGraph(navController: NavHostController) {
|
||||||
startDestination = Screen.Player.route,
|
startDestination = Screen.Player.route,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
|
composable(
|
||||||
|
route = Screen.Tabs.route,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("index") { type = NavType.IntType; defaultValue = 0 },
|
||||||
|
navArgument("playlistId") { type = NavType.LongType; defaultValue = -1L },
|
||||||
|
),
|
||||||
|
enterTransition = { detailEnter(reduce) },
|
||||||
|
exitTransition = { detailExit(reduce) },
|
||||||
|
popEnterTransition = { detailPopEnter(reduce) },
|
||||||
|
popExitTransition = { detailPopExit(reduce) },
|
||||||
|
) { backStackEntry ->
|
||||||
|
val args = backStackEntry.arguments
|
||||||
|
TabsScreen(
|
||||||
|
initialIndex = args?.getInt("index") ?: 0,
|
||||||
|
initialPlaylistId = args?.getLong("playlistId")?.takeIf { it > 0 },
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(
|
composable(
|
||||||
Screen.Player.route,
|
Screen.Player.route,
|
||||||
enterTransition = { hubEnter(reduce) },
|
enterTransition = { hubEnter(reduce) },
|
||||||
|
|
@ -46,10 +65,11 @@ fun NavGraph(navController: NavHostController) {
|
||||||
popExitTransition = { hubExit(reduce) },
|
popExitTransition = { hubExit(reduce) },
|
||||||
) {
|
) {
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
onNavigateToStations = { navController.navigate(Screen.Stations.route) },
|
onNavigateToStations = { navController.navigate(Screen.Tabs.createRoute(0)) },
|
||||||
onNavigateToLibrary = { navController.navigate(Screen.Library.createRoute()) },
|
onNavigateToDiscover = { navController.navigate(Screen.Tabs.createRoute(1)) },
|
||||||
onNavigateToStreams = { navController.navigate(Screen.Streams.route) },
|
onNavigateToLibrary = { navController.navigate(Screen.Tabs.createRoute(2)) },
|
||||||
onNavigateToPlaylists = { navController.navigate(Screen.Playlists.route) },
|
onNavigateToStreams = { navController.navigate(Screen.Tabs.createRoute(3)) },
|
||||||
|
onNavigateToPlaylists = { navController.navigate(Screen.Tabs.createRoute(4)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,8 @@ sealed class Screen(val route: String) {
|
||||||
data object Stations : Screen("stations")
|
data object Stations : Screen("stations")
|
||||||
data object Streams : Screen("streams")
|
data object Streams : Screen("streams")
|
||||||
data object Playlists : Screen("playlists")
|
data object Playlists : Screen("playlists")
|
||||||
|
data object Tabs : Screen("tabs?index={index}&playlistId={playlistId}") {
|
||||||
|
fun createRoute(index: Int = 0, playlistId: Long? = null): String =
|
||||||
|
"tabs?index=$index" + (playlistId?.let { "&playlistId=$it" } ?: "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
130
app/src/main/java/com/rada/ui/navigation/TabsScreen.kt
Normal file
130
app/src/main/java/com/rada/ui/navigation/TabsScreen.kt
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
package com.rada.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||||
|
import androidx.compose.material.icons.filled.LibraryMusic
|
||||||
|
import androidx.compose.material.icons.filled.Explore
|
||||||
|
import androidx.compose.material.icons.filled.Podcasts
|
||||||
|
import androidx.compose.material.icons.filled.Radio
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import com.rada.ui.library.LibraryScreen
|
||||||
|
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.LocalTokens
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The five peer tabs as a swipeable pager (Spotify's horizontal structure):
|
||||||
|
* Stations · Discover · Library · Streams · Playlists. Synced with the
|
||||||
|
* bottom bar; swiping follows the finger, tab taps animate. The mini-player
|
||||||
|
* sits above the nav bar so playback controls are always reachable.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TabsScreen(
|
||||||
|
initialIndex: Int,
|
||||||
|
initialPlaylistId: Long? = null,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onHome: () -> Unit,
|
||||||
|
) {
|
||||||
|
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
// Deep-link target for Library (playlist picker → open that playlist).
|
||||||
|
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
bottomBar = {
|
||||||
|
Column {
|
||||||
|
NowPlayingBar(
|
||||||
|
onNavigateToPlayer = onHome,
|
||||||
|
)
|
||||||
|
NavigationBar(
|
||||||
|
containerColor = LocalTokens.current.panel.copy(alpha = 0.94f),
|
||||||
|
) {
|
||||||
|
val items = listOf(
|
||||||
|
Triple("Stations", Icons.Default.Radio, 0),
|
||||||
|
Triple("Discover", Icons.Default.Explore, 1),
|
||||||
|
Triple("Library", Icons.Default.LibraryMusic, 2),
|
||||||
|
Triple("Streams", Icons.Default.Podcasts, 3),
|
||||||
|
Triple("Playlists", Icons.AutoMirrored.Filled.PlaylistAdd, 4),
|
||||||
|
)
|
||||||
|
items.forEach { (label, icon, page) ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = pagerState.settledPage == page,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { pagerState.animateScrollToPage(page) }
|
||||||
|
},
|
||||||
|
icon = { Icon(icon, contentDescription = label) },
|
||||||
|
label = { Text(label) },
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||||
|
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(bottom = padding.calculateBottomPadding()),
|
||||||
|
// Keep neighbors composed so swiping shows real content instantly
|
||||||
|
// and their ViewModels survive page changes.
|
||||||
|
beyondViewportPageCount = 1,
|
||||||
|
) { page ->
|
||||||
|
when (page) {
|
||||||
|
0 -> StationBrowserScreen(
|
||||||
|
onBack = onBack,
|
||||||
|
onHome = onHome,
|
||||||
|
onStationSelected = { /* stay on tab; bar shows the station */ },
|
||||||
|
)
|
||||||
|
1 -> com.rada.ui.discover.DiscoverScreen(
|
||||||
|
onBack = onBack,
|
||||||
|
onHome = onHome,
|
||||||
|
)
|
||||||
|
2 -> LibraryScreen(
|
||||||
|
onBack = onBack,
|
||||||
|
onHome = onHome,
|
||||||
|
initialPlaylistId = libraryPlaylistId,
|
||||||
|
)
|
||||||
|
3 -> StreamsScreen(
|
||||||
|
onBack = onBack,
|
||||||
|
onHome = onHome,
|
||||||
|
onStreamSelected = { },
|
||||||
|
)
|
||||||
|
4 -> PlaylistManagerScreen(
|
||||||
|
onBack = onBack,
|
||||||
|
onHome = onHome,
|
||||||
|
onPlaylistSelected = { playlistId ->
|
||||||
|
libraryPlaylistId = playlistId
|
||||||
|
scope.launch { pagerState.animateScrollToPage(2) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
package com.rada.ui.player
|
package com.rada.ui.player
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.LocalReduceMotion
|
||||||
import com.rada.ui.theme.barEnter
|
import com.rada.ui.theme.barEnter
|
||||||
|
import com.rada.ui.theme.verticalDragToDismiss
|
||||||
import com.rada.ui.theme.barExit
|
import com.rada.ui.theme.barExit
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
|
@ -22,6 +28,7 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.MusicNote
|
import androidx.compose.material.icons.filled.MusicNote
|
||||||
import androidx.compose.material.icons.filled.Radio
|
import androidx.compose.material.icons.filled.Radio
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.Bolt
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
|
@ -31,6 +38,8 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
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.setValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -55,7 +64,39 @@ fun NowPlayingBar(
|
||||||
val isPlaying by viewModel.isPlaying.collectAsState()
|
val isPlaying by viewModel.isPlaying.collectAsState()
|
||||||
val bufferState by viewModel.bufferState.collectAsState()
|
val bufferState by viewModel.bufferState.collectAsState()
|
||||||
val isLocal by viewModel.isLocal.collectAsState()
|
val isLocal by viewModel.isLocal.collectAsState()
|
||||||
val isVisible = stationName.isNotEmpty()
|
val streamMeta by viewModel.streamMeta.collectAsState()
|
||||||
|
val localMedia by viewModel.localMedia.collectAsState()
|
||||||
|
val isError = bufferState == PlaybackManager.BufferState.ERROR
|
||||||
|
val isBuffering =
|
||||||
|
bufferState == PlaybackManager.BufferState.BUFFERING ||
|
||||||
|
bufferState == PlaybackManager.BufferState.INITIALIZING
|
||||||
|
// Swipe-down (or quick drag-down) dismisses the bar until a *new*
|
||||||
|
// source starts playing — a floating thing should answer to gestures.
|
||||||
|
var dismissedFor by remember { mutableStateOf<String?>(null) }
|
||||||
|
val sourceKey = stationName
|
||||||
|
if (dismissedFor != null && dismissedFor != sourceKey) dismissedFor = null
|
||||||
|
val isVisible = stationName.isNotEmpty() && dismissedFor != sourceKey
|
||||||
|
|
||||||
|
// Spotify-style two-line bar: track (or station) + artist (or station).
|
||||||
|
val title: String
|
||||||
|
val subtitle: String
|
||||||
|
if (isLocal) {
|
||||||
|
title = localMedia?.first?.takeIf { it.isNotEmpty() } ?: stationName
|
||||||
|
subtitle = localMedia?.second.orEmpty()
|
||||||
|
} else {
|
||||||
|
val meta = streamMeta
|
||||||
|
val track = meta?.displayTitle?.takeIf { it.isNotEmpty() }
|
||||||
|
if (track != null) {
|
||||||
|
// Radio playing a tagged track: track on top, "artist · Station"
|
||||||
|
// below so the track is always attributable to its station.
|
||||||
|
title = track
|
||||||
|
val artist = meta?.displayArtist.orEmpty()
|
||||||
|
subtitle = if (artist.isNotEmpty()) "$artist · $stationName" else stationName
|
||||||
|
} else {
|
||||||
|
title = stationName
|
||||||
|
subtitle = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isVisible,
|
visible = isVisible,
|
||||||
|
|
@ -65,6 +106,10 @@ fun NowPlayingBar(
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.verticalDragToDismiss(
|
||||||
|
thresholdFraction = 0.4f,
|
||||||
|
onDismiss = { dismissedFor = sourceKey },
|
||||||
|
)
|
||||||
.padding(horizontal = 12.dp)
|
.padding(horizontal = 12.dp)
|
||||||
.clip(RoundedCornerShape(SatsAmpGlass.rCard))
|
.clip(RoundedCornerShape(SatsAmpGlass.rCard))
|
||||||
// glass: textSafe 90% overlay + 6% white + highlight border
|
// glass: textSafe 90% overlay + 6% white + highlight border
|
||||||
|
|
@ -105,12 +150,30 @@ fun NowPlayingBar(
|
||||||
.background(LocalTokens.current.accentFill),
|
.background(LocalTokens.current.accentFill),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (isPlaying && !isBuffering) {
|
||||||
if (isLocal) Icons.Default.MusicNote else Icons.Default.Radio,
|
EqualizerBars(
|
||||||
contentDescription = if (isLocal) "Now playing" else "Now playing station",
|
active = true,
|
||||||
tint = LocalTokens.current.accentOnFill,
|
color = LocalTokens.current.accentOnFill,
|
||||||
modifier = Modifier.size(24.dp),
|
barWidth = 3.dp,
|
||||||
)
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
} else if (isBuffering && !isError) {
|
||||||
|
EqualizerBars(
|
||||||
|
active = false,
|
||||||
|
color = LocalTokens.current.accentOnFill.copy(alpha = 0.7f),
|
||||||
|
barWidth = 3.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(22.dp)
|
||||||
|
.padding(2.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
if (isLocal) Icons.Default.MusicNote else Icons.Default.Radio,
|
||||||
|
contentDescription = "Paused",
|
||||||
|
tint = LocalTokens.current.accentOnFill,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -122,15 +185,86 @@ fun NowPlayingBar(
|
||||||
onClick = onNavigateToPlayer,
|
onClick = onNavigateToPlayer,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text(
|
Column {
|
||||||
text = stationName,
|
AnimatedContent(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
targetState = title,
|
||||||
fontWeight = FontWeight.SemiBold,
|
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
|
||||||
maxLines = 1,
|
label = "npTitle",
|
||||||
overflow = TextOverflow.Ellipsis,
|
) { t ->
|
||||||
color = LocalTokens.current.text,
|
Text(
|
||||||
|
text = t,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = LocalTokens.current.text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isError) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = { viewModel.retry() },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Couldn't reach · ",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Retry",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = tokens.accentText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ⚡ Support — tip the artist/station from anywhere. When the
|
||||||
|
// playing track maps to an artist with a known address, it
|
||||||
|
// arrives prefilled (works for every such artist, no wallet
|
||||||
|
// needed — copy it out if you like); otherwise manual entry.
|
||||||
|
var showTip by remember { mutableStateOf(false) }
|
||||||
|
val zapTarget by viewModel.zapTarget.collectAsState()
|
||||||
|
IconButton(
|
||||||
|
onClick = { showTip = true },
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Bolt,
|
||||||
|
contentDescription = "Support artist",
|
||||||
|
tint = LocalTokens.current.textSecondary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showTip) {
|
||||||
|
com.rada.ui.decentralized.TipSheet(
|
||||||
|
artistName = stationName,
|
||||||
|
trackTitle = title.takeIf { subtitle.isNotEmpty() },
|
||||||
|
initialAddress = zapTarget?.second.orEmpty(),
|
||||||
|
artistId = zapTarget?.first,
|
||||||
|
onDismiss = { showTip = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { viewModel.togglePlayPause() },
|
onClick = { viewModel.togglePlayPause() },
|
||||||
modifier = Modifier.size(44.dp),
|
modifier = Modifier.size(44.dp),
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,24 @@
|
||||||
package com.rada.ui.player
|
package com.rada.ui.player
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.rada.data.repository.LibraryRepository
|
||||||
|
import com.rada.data.wavlake.WavlakeRepository
|
||||||
import com.rada.player.PlaybackManager
|
import com.rada.player.PlaybackManager
|
||||||
|
import com.rada.recording.StreamMetadata
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class NowPlayingViewModel @Inject constructor(
|
class NowPlayingViewModel @Inject constructor(
|
||||||
private val playbackManager: PlaybackManager,
|
private val playbackManager: PlaybackManager,
|
||||||
|
private val library: LibraryRepository,
|
||||||
|
private val wavlake: WavlakeRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val currentStationName: StateFlow<String> = playbackManager.currentStationNameFlow
|
val currentStationName: StateFlow<String> = playbackManager.currentStationNameFlow
|
||||||
|
|
@ -16,8 +26,42 @@ class NowPlayingViewModel @Inject constructor(
|
||||||
val isPlaying: StateFlow<Boolean> = playbackManager.isPlayingFlow
|
val isPlaying: StateFlow<Boolean> = playbackManager.isPlayingFlow
|
||||||
val bufferState: StateFlow<PlaybackManager.BufferState> = playbackManager.bufferStateFlow
|
val bufferState: StateFlow<PlaybackManager.BufferState> = playbackManager.bufferStateFlow
|
||||||
val isLocal: StateFlow<Boolean> = playbackManager.isLocalFlow
|
val isLocal: StateFlow<Boolean> = playbackManager.isLocalFlow
|
||||||
|
val streamMeta: StateFlow<StreamMetadata?> = playbackManager.lastStreamMetaFlow
|
||||||
|
val localMedia: StateFlow<Pair<String, String>?> = playbackManager.localMediaFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (artistId, address) for the playing library track, when it maps to an
|
||||||
|
* artist with a known-or-resolvable Lightning Address. Works for every
|
||||||
|
* such artist, local import or Wavlake — radio streams have no identity
|
||||||
|
* and stay manual. Null means "unknown": the sheet opens blank as today.
|
||||||
|
*/
|
||||||
|
val zapTarget: StateFlow<Pair<String, String>?> =
|
||||||
|
playbackManager.currentLocalTrackIdFlow.flatMapLatest { id ->
|
||||||
|
flow {
|
||||||
|
val song = if (id != null) runCatching { library.getById(id) }.getOrNull() else null
|
||||||
|
if (song == null) {
|
||||||
|
emit(null)
|
||||||
|
return@flow
|
||||||
|
}
|
||||||
|
val artistId = song.wavlakeArtistId.takeIf { it.isNotBlank() }
|
||||||
|
?: run { emit(null); return@flow }
|
||||||
|
val address = wavlake.getKnownAddress(artistId)
|
||||||
|
?: wavlake.resolveLightningAddress(
|
||||||
|
artistId,
|
||||||
|
song.wavlakeArtistNpub.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
?: run { emit(null); return@flow }
|
||||||
|
emit(artistId to address)
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||||
|
|
||||||
fun togglePlayPause() {
|
fun togglePlayPause() {
|
||||||
playbackManager.togglePlayPause()
|
playbackManager.togglePlayPause()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reconnect the current station (mini-player error state). */
|
||||||
|
fun retry() {
|
||||||
|
val url = playbackManager.currentStationUrl ?: return
|
||||||
|
playbackManager.playStation(url, playbackManager.currentStationName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,15 @@ import com.rada.ui.theme.metadataExit
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
|
@ -34,8 +38,12 @@ import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
|
import androidx.compose.material.icons.filled.RepeatOne
|
||||||
|
import androidx.compose.material.icons.filled.Shuffle
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Error
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.icons.filled.Explore
|
||||||
import androidx.compose.material.icons.filled.LibraryMusic
|
import androidx.compose.material.icons.filled.LibraryMusic
|
||||||
import androidx.compose.material.icons.filled.MusicNote
|
import androidx.compose.material.icons.filled.MusicNote
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
|
@ -48,12 +56,12 @@ import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.SkipNext
|
import androidx.compose.material.icons.filled.SkipNext
|
||||||
import androidx.compose.material.icons.filled.SkipPrevious
|
import androidx.compose.material.icons.filled.SkipPrevious
|
||||||
import androidx.compose.material.icons.filled.Stop
|
|
||||||
import androidx.compose.material.icons.filled.LibraryMusic
|
import androidx.compose.material.icons.filled.LibraryMusic
|
||||||
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
|
@ -78,13 +86,16 @@ 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.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
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.material3.TextButton
|
|
||||||
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
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
@ -99,7 +110,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.rada.domain.model.Station
|
import com.rada.domain.model.Station
|
||||||
import com.rada.player.PlaybackManager
|
import com.rada.player.PlaybackManager
|
||||||
import com.rada.ui.components.AppHomeButton
|
import com.rada.ui.components.AppHomeButton
|
||||||
|
import com.rada.ui.components.EqualizerBars
|
||||||
import com.rada.ui.theme.LocalTokens
|
import com.rada.ui.theme.LocalTokens
|
||||||
|
import com.rada.ui.theme.SatsAmpGlass
|
||||||
|
import com.rada.ui.theme.SatsAmpGradients
|
||||||
|
import com.rada.ui.theme.pressDip
|
||||||
import com.rada.ui.theme.AppearanceDialog
|
import com.rada.ui.theme.AppearanceDialog
|
||||||
import com.rada.ui.theme.GradientEnd
|
import com.rada.ui.theme.GradientEnd
|
||||||
import com.rada.ui.theme.GradientMid
|
import com.rada.ui.theme.GradientMid
|
||||||
|
|
@ -108,6 +123,7 @@ import com.rada.ui.theme.PremiumCyan
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayerScreen(
|
fun PlayerScreen(
|
||||||
onNavigateToStations: () -> Unit,
|
onNavigateToStations: () -> Unit,
|
||||||
|
onNavigateToDiscover: () -> Unit,
|
||||||
onNavigateToLibrary: () -> Unit,
|
onNavigateToLibrary: () -> Unit,
|
||||||
onNavigateToStreams: () -> Unit,
|
onNavigateToStreams: () -> Unit,
|
||||||
onNavigateToPlaylists: () -> Unit,
|
onNavigateToPlaylists: () -> Unit,
|
||||||
|
|
@ -117,8 +133,10 @@ fun PlayerScreen(
|
||||||
val playlists by viewModel.playlists.collectAsState()
|
val playlists by viewModel.playlists.collectAsState()
|
||||||
val importMessage by viewModel.importMessage.collectAsState()
|
val importMessage by viewModel.importMessage.collectAsState()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
var showImportDialog by remember { mutableStateOf(false) }
|
var showImportDialog by remember { mutableStateOf(false) }
|
||||||
var showAppearance by remember { mutableStateOf(false) }
|
var showAppearance by remember { mutableStateOf(false) }
|
||||||
|
var showDecentralized by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val importSongsPicker = rememberLauncherForActivityResult(
|
val importSongsPicker = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.OpenMultipleDocuments()
|
ActivityResultContracts.OpenMultipleDocuments()
|
||||||
|
|
@ -189,14 +207,26 @@ fun PlayerScreen(
|
||||||
playlists = playlists,
|
playlists = playlists,
|
||||||
newPlaylistName = uiState.newPlaylistName,
|
newPlaylistName = uiState.newPlaylistName,
|
||||||
onNewPlaylistNameChange = viewModel::updateNewPlaylistName,
|
onNewPlaylistNameChange = viewModel::updateNewPlaylistName,
|
||||||
onPlaylistSelected = viewModel::addToPlaylist,
|
onPlaylistsConfirmed = viewModel::addToPlaylists,
|
||||||
onCreatePlaylist = { viewModel.createPlaylistAndAdd(uiState.newPlaylistName) },
|
onCreatePlaylist = { viewModel.createPlaylistAndAdd(uiState.newPlaylistName) },
|
||||||
onDismiss = viewModel::dismissPlaylistPicker,
|
onDismiss = viewModel::dismissPlaylistPicker,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showAppearance) {
|
if (showAppearance) {
|
||||||
AppearanceDialog(onDismiss = { showAppearance = false })
|
AppearanceDialog(
|
||||||
|
onDismiss = { showAppearance = false },
|
||||||
|
onOpenDecentralized = {
|
||||||
|
showAppearance = false
|
||||||
|
showDecentralized = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDecentralized) {
|
||||||
|
com.rada.ui.decentralized.DecentralizedSheet(
|
||||||
|
onDismiss = { showDecentralized = false },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -247,6 +277,17 @@ fun PlayerScreen(
|
||||||
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = false,
|
||||||
|
onClick = onNavigateToDiscover,
|
||||||
|
icon = { Icon(Icons.Default.Explore, contentDescription = "Discover") },
|
||||||
|
label = { Text("Discover") },
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||||
|
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||||
|
),
|
||||||
|
)
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = false,
|
selected = false,
|
||||||
onClick = onNavigateToLibrary,
|
onClick = onNavigateToLibrary,
|
||||||
|
|
@ -288,6 +329,9 @@ fun PlayerScreen(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
|
onSaveDisabled = { message ->
|
||||||
|
scope.launch { snackbarHostState.showSnackbar(message) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
|
|
@ -309,7 +353,8 @@ private fun EmptyStateView(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.padding(horizontal = 32.dp),
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 32.dp, vertical = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
|
|
@ -455,21 +500,24 @@ private fun NowPlayingView(
|
||||||
uiState: PlayerUiState,
|
uiState: PlayerUiState,
|
||||||
viewModel: PlayerViewModel,
|
viewModel: PlayerViewModel,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onSaveDisabled: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val reduce = LocalReduceMotion.current
|
val reduce = LocalReduceMotion.current
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.padding(horizontal = 24.dp),
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 24.dp, vertical = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
// Station card
|
// Station card
|
||||||
uiState.currentStation?.let { station ->
|
uiState.currentStation?.let { station ->
|
||||||
StationCard(
|
StationHero(
|
||||||
station = station,
|
station = station,
|
||||||
bufferState = uiState.bufferState,
|
bufferState = uiState.bufferState,
|
||||||
|
isPlaying = uiState.isPlaying,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Local library playback: a card for the song instead of a station.
|
// Local library playback: a card for the song instead of a station.
|
||||||
|
|
@ -569,38 +617,58 @@ private fun NowPlayingView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(48.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// Genre-matched Wavlake picks for this station (opt-in shelf:
|
||||||
|
// tapping plays, nothing auto-inserts, failures show nothing).
|
||||||
|
uiState.currentStation?.let { station ->
|
||||||
|
if (!uiState.isLocal && station.genre.isNotBlank()) {
|
||||||
|
com.rada.ui.discover.WavlakeFeaturedRow(
|
||||||
|
stationId = station.id,
|
||||||
|
stationGenre = station.genre,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save button
|
// Save button
|
||||||
|
val saveDisabledHint = when {
|
||||||
|
uiState.currentStation == null -> null
|
||||||
|
uiState.bufferState == PlaybackManager.BufferState.ERROR ->
|
||||||
|
"Couldn't buffer this station — try again"
|
||||||
|
else -> "Waiting for the audio buffer…"
|
||||||
|
}
|
||||||
SaveSongButton(
|
SaveSongButton(
|
||||||
canSave = uiState.currentStation != null && uiState.bufferState == PlaybackManager.BufferState.READY,
|
canSave = uiState.currentStation != null && uiState.bufferState == PlaybackManager.BufferState.READY,
|
||||||
saveState = uiState.saveState,
|
saveState = uiState.saveState,
|
||||||
onClick = { viewModel.saveSong() },
|
onClick = { viewModel.saveSong() },
|
||||||
|
onDisabledTap = { saveDisabledHint?.let(onSaveDisabled) },
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
// Playback controls — conventional transport order: stop · prev · play · next
|
// Playback controls: prev · play · next.
|
||||||
|
// Every tap answers instantly (haptic tick + press dip) so feedback
|
||||||
|
// never waits on the stream catching up.
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
fun tap(action: () -> Unit) {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
val transportEnabled = uiState.currentStation != null || uiState.isLocal
|
||||||
|
// Tapped play but audio hasn't resumed yet (reconnecting, initial
|
||||||
|
// buffering): show it instead of a play icon that promises sound.
|
||||||
|
val waitingForAudio = transportEnabled && !uiState.isPlaying &&
|
||||||
|
(uiState.bufferState == PlaybackManager.BufferState.BUFFERING ||
|
||||||
|
uiState.bufferState == PlaybackManager.BufferState.INITIALIZING)
|
||||||
|
val playInteraction = remember { MutableInteractionSource() }
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { viewModel.stop() },
|
onClick = { tap(viewModel::previous) },
|
||||||
enabled = uiState.currentStation != null || uiState.isLocal,
|
enabled = transportEnabled,
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Stop,
|
|
||||||
contentDescription = "Stop",
|
|
||||||
modifier = Modifier.size(28.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = { viewModel.previousStation() },
|
|
||||||
enabled = uiState.currentStation != null || uiState.isLocal,
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.SkipPrevious,
|
Icons.Default.SkipPrevious,
|
||||||
|
|
@ -612,16 +680,24 @@ private fun NowPlayingView(
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
FilledIconButton(
|
FilledIconButton(
|
||||||
onClick = { viewModel.togglePlayPause() },
|
onClick = { tap(viewModel::togglePlayPause) },
|
||||||
enabled = uiState.currentStation != null || uiState.isLocal,
|
enabled = transportEnabled,
|
||||||
modifier = Modifier.size(72.dp),
|
modifier = Modifier
|
||||||
|
.size(72.dp)
|
||||||
|
.pressDip(playInteraction, reduce),
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
colors = IconButtonDefaults.filledIconButtonColors(
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
|
interactionSource = playInteraction,
|
||||||
) {
|
) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = uiState.isPlaying,
|
// 0 = waiting, 1 = playing, 2 = paused/stopped.
|
||||||
|
targetState = when {
|
||||||
|
waitingForAudio -> 0
|
||||||
|
uiState.isPlaying -> 1
|
||||||
|
else -> 2
|
||||||
|
},
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
if (reduce)
|
if (reduce)
|
||||||
fadeIn(tween(0)) togetherWith fadeOut(tween(0))
|
fadeIn(tween(0)) togetherWith fadeOut(tween(0))
|
||||||
|
|
@ -632,28 +708,82 @@ private fun NowPlayingView(
|
||||||
fadeOut(tween(90))
|
fadeOut(tween(90))
|
||||||
},
|
},
|
||||||
label = "playPause",
|
label = "playPause",
|
||||||
) { playing ->
|
) { visual ->
|
||||||
Icon(
|
when (visual) {
|
||||||
if (playing) Icons.Default.Pause else Icons.Default.PlayArrow,
|
0 -> CircularProgressIndicator(
|
||||||
contentDescription = if (playing) "Pause" else "Play",
|
modifier = Modifier.size(32.dp),
|
||||||
modifier = Modifier.size(36.dp),
|
strokeWidth = 3.dp,
|
||||||
)
|
)
|
||||||
|
1 -> Icon(
|
||||||
|
Icons.Default.Pause,
|
||||||
|
contentDescription = "Pause",
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
)
|
||||||
|
else -> Icon(
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
contentDescription = "Play",
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { viewModel.nextStation() },
|
onClick = { tap(viewModel::next) },
|
||||||
enabled = uiState.currentStation != null,
|
enabled = transportEnabled,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.SkipNext,
|
Icons.Default.SkipNext,
|
||||||
contentDescription = "Next station",
|
contentDescription = if (uiState.isLocal) "Next track" else "Next station",
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Playback options: repeat · shuffle (local queue only).
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.cycleRepeat() },
|
||||||
|
enabled = uiState.isLocal,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (uiState.repeatMode == androidx.media3.common.Player.REPEAT_MODE_ONE)
|
||||||
|
Icons.Default.RepeatOne
|
||||||
|
else
|
||||||
|
Icons.Default.Repeat,
|
||||||
|
contentDescription = "Repeat",
|
||||||
|
tint = if (uiState.repeatMode != androidx.media3.common.Player.REPEAT_MODE_OFF)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
LocalTokens.current.textSecondary.copy(
|
||||||
|
alpha = if (uiState.isLocal) 1f else 0.38f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.toggleShuffle() },
|
||||||
|
enabled = uiState.isLocal,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Shuffle,
|
||||||
|
contentDescription = "Shuffle",
|
||||||
|
tint = if (uiState.shuffleEnabled)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
LocalTokens.current.textSecondary.copy(
|
||||||
|
alpha = if (uiState.isLocal) 1f else 0.38f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -662,12 +792,13 @@ private fun PlaylistPickerDialog(
|
||||||
playlists: List<com.rada.data.local.entity.PlaylistEntity>,
|
playlists: List<com.rada.data.local.entity.PlaylistEntity>,
|
||||||
newPlaylistName: String,
|
newPlaylistName: String,
|
||||||
onNewPlaylistNameChange: (String) -> Unit,
|
onNewPlaylistNameChange: (String) -> Unit,
|
||||||
onPlaylistSelected: (Long) -> Unit,
|
onPlaylistsConfirmed: (List<Long>) -> Unit,
|
||||||
onCreatePlaylist: () -> Unit,
|
onCreatePlaylist: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var showNewPlaylistInput by remember { mutableStateOf(false) }
|
var showNewPlaylistInput by remember { mutableStateOf(false) }
|
||||||
var playlistSearchQuery by remember { mutableStateOf("") }
|
var playlistSearchQuery by remember { mutableStateOf("") }
|
||||||
|
var selectedIds by remember { mutableStateOf(setOf<Long>()) }
|
||||||
|
|
||||||
val filteredPlaylists = remember(playlists, playlistSearchQuery) {
|
val filteredPlaylists = remember(playlists, playlistSearchQuery) {
|
||||||
if (playlistSearchQuery.isBlank()) playlists
|
if (playlistSearchQuery.isBlank()) playlists
|
||||||
|
|
@ -683,7 +814,7 @@ private fun PlaylistPickerDialog(
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
title = { Text("Add to Playlist") },
|
title = { Text("Add to playlists") },
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
if (playlists.isNotEmpty()) {
|
if (playlists.isNotEmpty()) {
|
||||||
|
|
@ -714,18 +845,25 @@ private fun PlaylistPickerDialog(
|
||||||
modifier = Modifier.heightIn(max = 200.dp),
|
modifier = Modifier.heightIn(max = 200.dp),
|
||||||
) {
|
) {
|
||||||
items(filteredPlaylists) { playlist ->
|
items(filteredPlaylists) { playlist ->
|
||||||
|
val checked = playlist.id in selectedIds
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { onPlaylistSelected(playlist.id) }
|
.clickable {
|
||||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
selectedIds =
|
||||||
|
if (checked) selectedIds - playlist.id
|
||||||
|
else selectedIds + playlist.id
|
||||||
|
}
|
||||||
|
.padding(vertical = 6.dp, horizontal = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Checkbox(
|
||||||
Icons.AutoMirrored.Filled.PlaylistAdd,
|
checked = checked,
|
||||||
contentDescription = null,
|
onCheckedChange = { isChecked ->
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
selectedIds =
|
||||||
modifier = Modifier.size(20.dp),
|
if (isChecked) selectedIds + playlist.id
|
||||||
|
else selectedIds - playlist.id
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -796,7 +934,14 @@ private fun PlaylistPickerDialog(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {},
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onPlaylistsConfirmed(selectedIds.toList()) },
|
||||||
|
enabled = selectedIds.isNotEmpty(),
|
||||||
|
) {
|
||||||
|
Text(if (selectedIds.size == 1) "Add" else "Add to ${selectedIds.size}")
|
||||||
|
}
|
||||||
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = onDismiss) {
|
||||||
Text("Cancel")
|
Text("Cancel")
|
||||||
|
|
@ -810,8 +955,10 @@ private fun SaveSongButton(
|
||||||
canSave: Boolean,
|
canSave: Boolean,
|
||||||
saveState: PlaybackManager.SaveState,
|
saveState: PlaybackManager.SaveState,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onDisabledTap: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val reduce = LocalReduceMotion.current
|
val reduce = LocalReduceMotion.current
|
||||||
|
val enabled = canSave && saveState != PlaybackManager.SaveState.SAVING
|
||||||
val containerColor = when {
|
val containerColor = when {
|
||||||
saveState == PlaybackManager.SaveState.SAVING -> MaterialTheme.colorScheme.secondary
|
saveState == PlaybackManager.SaveState.SAVING -> MaterialTheme.colorScheme.secondary
|
||||||
saveState == PlaybackManager.SaveState.SUCCESS -> Color(0xFF10B981)
|
saveState == PlaybackManager.SaveState.SUCCESS -> Color(0xFF10B981)
|
||||||
|
|
@ -829,17 +976,18 @@ private fun SaveSongButton(
|
||||||
}
|
}
|
||||||
|
|
||||||
FilledIconButton(
|
FilledIconButton(
|
||||||
onClick = onClick,
|
// Always enabled at the framework level so a tap always lands: when
|
||||||
enabled = canSave && saveState != PlaybackManager.SaveState.SAVING,
|
// not ready we explain why instead of eating the press silently.
|
||||||
|
// Visual enabled/disabled state is driven manually via the colors.
|
||||||
|
onClick = { if (canSave && saveState != PlaybackManager.SaveState.SAVING) onClick() else onDisabledTap() },
|
||||||
|
enabled = true,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(80.dp)
|
.size(80.dp)
|
||||||
.clip(RoundedCornerShape(20.dp)),
|
.clip(RoundedCornerShape(20.dp)),
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = RoundedCornerShape(20.dp),
|
||||||
colors = IconButtonDefaults.filledIconButtonColors(
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
containerColor = containerColor,
|
containerColor = if (enabled) containerColor else containerColor.copy(alpha = 0.5f),
|
||||||
contentColor = contentColor,
|
contentColor = if (enabled) contentColor else contentColor.copy(alpha = 0.5f),
|
||||||
disabledContainerColor = containerColor.copy(alpha = 0.5f),
|
|
||||||
disabledContentColor = contentColor.copy(alpha = 0.5f),
|
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
|
|
@ -888,73 +1036,90 @@ private fun SaveSongButton(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StationCard(
|
private fun StationHero(
|
||||||
station: Station,
|
station: Station,
|
||||||
bufferState: PlaybackManager.BufferState,
|
bufferState: PlaybackManager.BufferState,
|
||||||
|
isPlaying: Boolean,
|
||||||
) {
|
) {
|
||||||
|
// Big-artwork hero: the accent gradient is the "album art" for a live
|
||||||
|
// station, with the buffer state as a status chip and the equalizer as
|
||||||
|
// the playing signature. All colors come from the active accent tokens.
|
||||||
|
val tokens = LocalTokens.current
|
||||||
androidx.compose.material3.Card(
|
androidx.compose.material3.Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = Color.Transparent,
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(SatsAmpGlass.rCard),
|
||||||
) {
|
) {
|
||||||
Row(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.aspectRatio(16f / 10f)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
.background(SatsAmpGradients.accentBrush()),
|
||||||
) {
|
) {
|
||||||
Box(
|
Icon(
|
||||||
|
Icons.Default.Radio,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = tokens.accentOnFill.copy(alpha = 0.30f),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(128.dp)
|
||||||
.clip(CircleShape)
|
.align(Alignment.Center),
|
||||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
|
)
|
||||||
contentAlignment = Alignment.Center,
|
val statusLabel = when (bufferState) {
|
||||||
) {
|
PlaybackManager.BufferState.INITIALIZING,
|
||||||
Icon(
|
PlaybackManager.BufferState.BUFFERING -> "Buffering…"
|
||||||
Icons.Default.Radio,
|
PlaybackManager.BufferState.READY -> "Ready"
|
||||||
contentDescription = null,
|
PlaybackManager.BufferState.ERROR -> "Error"
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
PlaybackManager.BufferState.IDLE -> null
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
statusLabel?.let { label ->
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
androidx.compose.material3.Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.padding(12.dp),
|
||||||
|
color = tokens.textSafe,
|
||||||
|
shape = RoundedCornerShape(SatsAmpGlass.rInput),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = tokens.text,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
if (isPlaying) {
|
||||||
|
EqualizerBars(
|
||||||
|
active = true,
|
||||||
|
color = tokens.accentOnFill,
|
||||||
|
modifier = Modifier.padding(bottom = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = station.name,
|
text = station.name,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = tokens.accentOnFill,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
if (station.genre.isNotEmpty()) {
|
if (station.genre.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = station.genre,
|
text = station.genre,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = tokens.accentOnFill.copy(alpha = 0.85f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BufferIndicator(bufferState = bufferState)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BufferIndicator(bufferState: PlaybackManager.BufferState) {
|
|
||||||
val (text, color) = when (bufferState) {
|
|
||||||
PlaybackManager.BufferState.IDLE -> "" to MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
PlaybackManager.BufferState.INITIALIZING -> "Init" to PremiumCyan
|
|
||||||
PlaybackManager.BufferState.BUFFERING -> "Buffering" to PremiumCyan
|
|
||||||
PlaybackManager.BufferState.READY -> "Ready" to MaterialTheme.colorScheme.primary
|
|
||||||
PlaybackManager.BufferState.ERROR -> "Error" to MaterialTheme.colorScheme.error
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = color,
|
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -107,10 +107,14 @@ class PlayerViewModel @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (state == PlaybackManager.SaveState.SUCCESS && result?.songId != null) {
|
if (state == PlaybackManager.SaveState.SUCCESS && result?.songId != null) {
|
||||||
|
val meta = playbackManager.lastStreamMetaFlow.value
|
||||||
|
val title = meta?.displayTitle?.takeIf { it.isNotEmpty() }
|
||||||
|
?: result.fileName?.substringBeforeLast(".mp3").orEmpty()
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
showPlaylistPicker = true,
|
showPlaylistPicker = true,
|
||||||
pickedSongId = result.songId,
|
pickedSongId = result.songId,
|
||||||
|
pickedSongTitle = title,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +135,7 @@ class PlayerViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
playbackManager.listener = playbackListener
|
playbackManager.listener = playbackListener
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
@ -143,9 +147,49 @@ class PlayerViewModel @Inject constructor(
|
||||||
localTitle = playbackManager.localMediaFlow.value?.first.orEmpty(),
|
localTitle = playbackManager.localMediaFlow.value?.first.orEmpty(),
|
||||||
localArtist = playbackManager.localMediaFlow.value?.second.orEmpty(),
|
localArtist = playbackManager.localMediaFlow.value?.second.orEmpty(),
|
||||||
isPlaying = playbackManager.isPlaying,
|
isPlaying = playbackManager.isPlaying,
|
||||||
|
// Seed live recorder state: no transition event fires for a
|
||||||
|
// state that is already READY, so a fresh screen must read it.
|
||||||
|
bufferState = playbackManager.bufferState,
|
||||||
|
saveState = playbackManager.saveState,
|
||||||
|
// A save that finished while this screen was away (event went
|
||||||
|
// to a detached listener) would otherwise lose its playlist
|
||||||
|
// picker: surface it now instead.
|
||||||
|
showPlaylistPicker = pendingPicker() != null,
|
||||||
|
pickedSongId = pendingPicker()?.songId,
|
||||||
|
pickedSongTitle = pendingPicker()?.title.orEmpty(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
restoreCachedStreamMeta()
|
restoreCachedStreamMeta()
|
||||||
|
|
||||||
|
// Keep local queue state (title/artist across track changes) live.
|
||||||
|
viewModelScope.launch {
|
||||||
|
playbackManager.localMediaFlow.collect { local ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
localTitle = local?.first.orEmpty(),
|
||||||
|
localArtist = local?.second.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
playbackManager.speedFlow.collect { v -> _uiState.update { it.copy(speed = v) } }
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
playbackManager.sleepRemainingMsFlow.collect { ms ->
|
||||||
|
_uiState.update { it.copy(sleepRemainingMs = ms) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
playbackManager.repeatModeFlow.collect { mode ->
|
||||||
|
_uiState.update { it.copy(repeatMode = mode) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
playbackManager.shuffleEnabledFlow.collect { on ->
|
||||||
|
_uiState.update { it.copy(shuffleEnabled = on) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-apply the last ICY metadata so a reopened screen isn't blank. */
|
/** Re-apply the last ICY metadata so a reopened screen isn't blank. */
|
||||||
|
|
@ -191,6 +235,38 @@ class PlayerViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Transport next: queue advance for local playback, station cycle for radio. */
|
||||||
|
fun next() {
|
||||||
|
if (_uiState.value.isLocal) { playbackManager.seekToNextItem(); return }
|
||||||
|
nextStation()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun previous() {
|
||||||
|
if (_uiState.value.isLocal) { playbackManager.seekToPreviousItem(); return }
|
||||||
|
previousStation()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpeed(speed: Float) = playbackManager.setSpeed(speed)
|
||||||
|
|
||||||
|
fun cycleRepeat() {
|
||||||
|
val next = when (_uiState.value.repeatMode) {
|
||||||
|
androidx.media3.common.Player.REPEAT_MODE_OFF ->
|
||||||
|
androidx.media3.common.Player.REPEAT_MODE_ALL
|
||||||
|
androidx.media3.common.Player.REPEAT_MODE_ALL ->
|
||||||
|
androidx.media3.common.Player.REPEAT_MODE_ONE
|
||||||
|
else -> androidx.media3.common.Player.REPEAT_MODE_OFF
|
||||||
|
}
|
||||||
|
playbackManager.setRepeatMode(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleShuffle() {
|
||||||
|
playbackManager.setShuffleEnabled(!_uiState.value.shuffleEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startSleepTimer(minutes: Int) = playbackManager.startSleepTimer(minutes)
|
||||||
|
|
||||||
|
fun cancelSleepTimer() = playbackManager.cancelSleepTimer()
|
||||||
|
|
||||||
fun nextStation() {
|
fun nextStation() {
|
||||||
val stations = _uiState.value.stations
|
val stations = _uiState.value.stations
|
||||||
if (stations.isEmpty()) return
|
if (stations.isEmpty()) return
|
||||||
|
|
@ -222,25 +298,42 @@ class PlayerViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissPlaylistPicker() {
|
fun dismissPlaylistPicker() {
|
||||||
|
val pendingId = _uiState.value.pickedSongId
|
||||||
|
val title = _uiState.value.pickedSongTitle
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
showPlaylistPicker = false,
|
showPlaylistPicker = false,
|
||||||
pickedSongId = null,
|
pickedSongId = null,
|
||||||
|
pickedSongTitle = "",
|
||||||
newPlaylistName = "",
|
newPlaylistName = "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Dismissed without adding: the song IS saved, so say so — otherwise
|
||||||
|
// a successful save leaves no trace at all.
|
||||||
|
if (pendingId != null) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
playlistPickerMessage = if (title.isNotBlank()) "Saved “$title” to Library"
|
||||||
|
else "Saved to Library",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addToPlaylist(playlistId: Long) {
|
/** Adds the picked song to one or more playlists, then closes the picker. */
|
||||||
|
fun addToPlaylists(playlistIds: List<Long>) {
|
||||||
val songId = _uiState.value.pickedSongId ?: return
|
val songId = _uiState.value.pickedSongId ?: return
|
||||||
|
if (playlistIds.isEmpty()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playlistRepository.addSongToPlaylist(playlistId, songId)
|
playlistIds.forEach { playlistRepository.addSongToPlaylist(it, songId) }
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
showPlaylistPicker = false,
|
showPlaylistPicker = false,
|
||||||
pickedSongId = null,
|
pickedSongId = null,
|
||||||
|
pickedSongTitle = "",
|
||||||
newPlaylistName = "",
|
newPlaylistName = "",
|
||||||
playlistPickerMessage = "Added to playlist",
|
playlistPickerMessage = if (playlistIds.size == 1) "Added to playlist"
|
||||||
|
else "Added to ${playlistIds.size} playlists",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -255,6 +348,7 @@ class PlayerViewModel @Inject constructor(
|
||||||
it.copy(
|
it.copy(
|
||||||
showPlaylistPicker = false,
|
showPlaylistPicker = false,
|
||||||
pickedSongId = null,
|
pickedSongId = null,
|
||||||
|
pickedSongTitle = "",
|
||||||
newPlaylistName = "",
|
newPlaylistName = "",
|
||||||
playlistPickerMessage = "Created \"$name\" and added song",
|
playlistPickerMessage = "Created \"$name\" and added song",
|
||||||
)
|
)
|
||||||
|
|
@ -287,14 +381,41 @@ class PlayerViewModel @Inject constructor(
|
||||||
isPlaying = playbackManager.isPlaying,
|
isPlaying = playbackManager.isPlaying,
|
||||||
artist = if (isLocal) local?.second.orEmpty() else playbackManager.currentStationName,
|
artist = if (isLocal) local?.second.orEmpty() else playbackManager.currentStationName,
|
||||||
title = if (isLocal) local?.first.orEmpty() else it.title,
|
title = if (isLocal) local?.first.orEmpty() else it.title,
|
||||||
|
// Same seeding as init: returning to this screen must reflect
|
||||||
|
// the recorder's current state, not IDLE — including a fresh
|
||||||
|
// save whose picker event went to a detached listener.
|
||||||
|
bufferState = playbackManager.bufferState,
|
||||||
|
saveState = playbackManager.saveState,
|
||||||
|
showPlaylistPicker = pendingPicker() != null || it.showPlaylistPicker,
|
||||||
|
pickedSongId = pendingPicker()?.songId ?: it.pickedSongId,
|
||||||
|
pickedSongTitle = pendingPicker()?.title ?: it.pickedSongTitle,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!isLocal) restoreCachedStreamMeta()
|
if (!isLocal) restoreCachedStreamMeta()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A save awaiting its playlist picker, if the manager holds one. */
|
||||||
|
private fun pendingPicker(): PendingPicker? {
|
||||||
|
val result = playbackManager.lastSaveResult ?: return null
|
||||||
|
if (playbackManager.saveState != PlaybackManager.SaveState.SUCCESS) return null
|
||||||
|
val id = result.songId ?: return null
|
||||||
|
val title = playbackManager.lastStreamMetaFlow.value?.displayTitle?.takeIf { it.isNotEmpty() }
|
||||||
|
?: result.fileName?.substringBeforeLast(".mp3").orEmpty()
|
||||||
|
return PendingPicker(id, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class PendingPicker(val songId: Long, val title: String)
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
playbackManager.listener = null
|
// Only detach our own listener: during mini-player navigation the new
|
||||||
|
// screen attaches first and the old entry is cleared after — a blind
|
||||||
|
// null here killed the NEW screen's events, leaving its buttons
|
||||||
|
// (save spinner/check/dialog, pause icon) permanently dead while the
|
||||||
|
// manager kept working headlessly.
|
||||||
|
if (playbackManager.listener === playbackListener) {
|
||||||
|
playbackManager.listener = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -314,8 +435,13 @@ data class PlayerUiState(
|
||||||
val bufferBytesWritten: Long = 0,
|
val bufferBytesWritten: Long = 0,
|
||||||
val saveState: PlaybackManager.SaveState = PlaybackManager.SaveState.IDLE,
|
val saveState: PlaybackManager.SaveState = PlaybackManager.SaveState.IDLE,
|
||||||
val lastSaveResult: SaveSongUseCase.SaveResult? = null,
|
val lastSaveResult: SaveSongUseCase.SaveResult? = null,
|
||||||
|
val speed: Float = 1f,
|
||||||
|
val sleepRemainingMs: Long = 0L,
|
||||||
|
val repeatMode: Int = androidx.media3.common.Player.REPEAT_MODE_OFF,
|
||||||
|
val shuffleEnabled: Boolean = false,
|
||||||
val showPlaylistPicker: Boolean = false,
|
val showPlaylistPicker: Boolean = false,
|
||||||
val pickedSongId: Long? = null,
|
val pickedSongId: Long? = null,
|
||||||
|
val pickedSongTitle: String = "",
|
||||||
val newPlaylistName: String = "",
|
val newPlaylistName: String = "",
|
||||||
val playlistPickerMessage: String? = null,
|
val playlistPickerMessage: String? = null,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ package com.rada.ui.stations
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
|
@ -15,36 +15,49 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material.icons.filled.StarBorder
|
import androidx.compose.material.icons.filled.StarBorder
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Radio
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
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
|
||||||
|
|
@ -60,24 +73,11 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.rada.domain.model.Station
|
import com.rada.domain.model.Station
|
||||||
import com.rada.ui.components.AppHomeButton
|
import com.rada.ui.components.AppHomeButton
|
||||||
|
import com.rada.ui.components.EqualizerBars
|
||||||
|
import com.rada.ui.theme.LocalReduceMotion
|
||||||
import com.rada.ui.theme.LocalTokens
|
import com.rada.ui.theme.LocalTokens
|
||||||
|
import com.rada.ui.theme.SatsAmpGlass
|
||||||
private val genreColors = mapOf(
|
import com.rada.ui.theme.SatsAmpGradients
|
||||||
"Lofi" to 0xFF7C3AED,
|
|
||||||
"Synthwave" to 0xFFEC4899,
|
|
||||||
"EDM" to 0xFF06B6D4,
|
|
||||||
"Phonk" to 0xFFEF4444,
|
|
||||||
"Classical" to 0xFFF59E0B,
|
|
||||||
"Strings" to 0xFF10B981,
|
|
||||||
"Ambient" to 0xFF3B82F6,
|
|
||||||
"Electronic" to 0xFF06B6D4,
|
|
||||||
"Jazz" to 0xFFF59E0B,
|
|
||||||
"Eclectic" to 0xFFEC4899,
|
|
||||||
"Oldies" to 0xFFF59E0B,
|
|
||||||
"Rock" to 0xFFEF4444,
|
|
||||||
"Hip-Hop" to 0xFFEC4899,
|
|
||||||
"Custom" to 0xFF7C3AED,
|
|
||||||
)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -92,8 +92,46 @@ fun StationBrowserScreen(
|
||||||
val favorites by viewModel.favorites.collectAsState()
|
val favorites by viewModel.favorites.collectAsState()
|
||||||
val directoryState by viewModel.directoryState.collectAsState()
|
val directoryState by viewModel.directoryState.collectAsState()
|
||||||
val playingUrl by viewModel.playingUrl.collectAsState()
|
val playingUrl by viewModel.playingUrl.collectAsState()
|
||||||
|
val hiddenIds by viewModel.hiddenIds.collectAsState()
|
||||||
|
val notice by viewModel.notice.collectAsState()
|
||||||
|
val wavlakeVersion by viewModel.wavlakeVersion.collectAsState()
|
||||||
|
val wavlakeArtists by viewModel.wavlakeArtists.collectAsState()
|
||||||
|
// Entry-scoped Wavlake VM (powers the artist dialog; independent of Discover).
|
||||||
|
val wavlakeVm: com.rada.ui.discover.WavlakeViewModel = hiltViewModel()
|
||||||
|
var wavlakeArtistDialog by remember {
|
||||||
|
mutableStateOf<com.rada.domain.model.WavlakeArtist?>(null)
|
||||||
|
}
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var showAddDialog by remember { mutableStateOf(false) }
|
var showAddDialog by remember { mutableStateOf(false) }
|
||||||
var renameTarget by remember { mutableStateOf<Station?>(null) }
|
var renameTarget by remember { mutableStateOf<Station?>(null) }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Starring moves the station to the pinned top section: bring it into
|
||||||
|
// view instead of leaving the list scrolled where it was. Only on
|
||||||
|
// additions — unstarring keeps your scroll position. Skipped on first
|
||||||
|
// composition (nothing actually changed).
|
||||||
|
val reduceMotion = LocalReduceMotion.current
|
||||||
|
var lastFavCount by remember { mutableStateOf<Int?>(null) }
|
||||||
|
LaunchedEffect(favorites.size) {
|
||||||
|
val prev = lastFavCount
|
||||||
|
lastFavCount = favorites.size
|
||||||
|
if (prev != null && favorites.size > prev) {
|
||||||
|
if (reduceMotion) listState.scrollToItem(0)
|
||||||
|
else listState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(notice) {
|
||||||
|
notice?.let { message ->
|
||||||
|
val result = snackbarHostState.showSnackbar(
|
||||||
|
message = message,
|
||||||
|
actionLabel = "Undo",
|
||||||
|
withDismissAction = true,
|
||||||
|
)
|
||||||
|
if (result == SnackbarResult.ActionPerformed) viewModel.undoDelete()
|
||||||
|
viewModel.consumeNotice()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showAddDialog) {
|
if (showAddDialog) {
|
||||||
AddStationDialog(
|
AddStationDialog(
|
||||||
|
|
@ -145,6 +183,7 @@ fun StationBrowserScreen(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -173,17 +212,93 @@ fun StationBrowserScreen(
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Browse chips: Apple-style genre filter above the list ──
|
||||||
|
val genres = stationsByGenre.keys.toList()
|
||||||
|
var activeGenre by remember { mutableStateOf<String?>(null) }
|
||||||
|
val effectiveGenre = activeGenre?.takeIf { it in genres }
|
||||||
|
val visibleGenres = if (searchQuery.isBlank() && effectiveGenre != null) {
|
||||||
|
stationsByGenre.filterKeys { it == effectiveGenre }
|
||||||
|
} else {
|
||||||
|
stationsByGenre
|
||||||
|
}
|
||||||
|
if (searchQuery.isBlank() && genres.size > 1) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||||
|
horizontal = 16.dp,
|
||||||
|
vertical = 4.dp,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
item(key = "chip-all") {
|
||||||
|
FilterChip(
|
||||||
|
selected = effectiveGenre == null,
|
||||||
|
onClick = { activeGenre = null },
|
||||||
|
label = { Text("All") },
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
containerColor = LocalTokens.current.panel,
|
||||||
|
labelColor = LocalTokens.current.textSecondary,
|
||||||
|
selectedContainerColor = LocalTokens.current.accentFill.copy(alpha = 0.22f),
|
||||||
|
selectedLabelColor = LocalTokens.current.accentText,
|
||||||
|
),
|
||||||
|
border = FilterChipDefaults.filterChipBorder(
|
||||||
|
borderColor = LocalTokens.current.border,
|
||||||
|
selectedBorderColor = LocalTokens.current.accentFill,
|
||||||
|
enabled = true,
|
||||||
|
selected = effectiveGenre == null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(genres, key = { "chip-$it" }) { genre ->
|
||||||
|
FilterChip(
|
||||||
|
selected = effectiveGenre == genre,
|
||||||
|
onClick = {
|
||||||
|
activeGenre = if (effectiveGenre == genre) null else genre
|
||||||
|
},
|
||||||
|
label = { Text(genre) },
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
containerColor = LocalTokens.current.panel,
|
||||||
|
labelColor = LocalTokens.current.textSecondary,
|
||||||
|
selectedContainerColor = LocalTokens.current.accentFill.copy(alpha = 0.22f),
|
||||||
|
selectedLabelColor = LocalTokens.current.accentText,
|
||||||
|
),
|
||||||
|
border = FilterChipDefaults.filterChipBorder(
|
||||||
|
borderColor = LocalTokens.current.border,
|
||||||
|
selectedBorderColor = LocalTokens.current.accentFill,
|
||||||
|
enabled = true,
|
||||||
|
selected = effectiveGenre == genre,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Now-playing hero: jump back into the Player ──
|
||||||
|
val nowPlayingStation = remember(stationsByGenre, favorites, playingUrl) {
|
||||||
|
playingUrl?.let { url ->
|
||||||
|
(favorites + stationsByGenre.values.flatten()).firstOrNull { it.url == url }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
|
if (searchQuery.isBlank() && nowPlayingStation != null) {
|
||||||
|
item(key = "now-playing") {
|
||||||
|
NowPlayingHero(
|
||||||
|
station = nowPlayingStation,
|
||||||
|
onOpenPlayer = onHome,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
// ── Favorites row: pinned above everything ──
|
// ── Favorites row: pinned above everything ──
|
||||||
if (favorites.isNotEmpty()) {
|
if (favorites.isNotEmpty()) {
|
||||||
item { SectionHeader("Starred", Icons.Default.Star) }
|
item { SectionHeader("Starred", Icons.Default.Star) }
|
||||||
items(favorites, key = { "fav-${it.id}" }) { station ->
|
items(favorites, key = { "fav-${it.id}" }, contentType = { "station" }) { station ->
|
||||||
StationCard(
|
StationCard(
|
||||||
station = station,
|
station = station,
|
||||||
isCustom = viewModel.isCustomStation(station.id),
|
|
||||||
isFavorite = true,
|
isFavorite = true,
|
||||||
isPlaying = playingUrl == station.url,
|
isPlaying = playingUrl == station.url,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
@ -192,20 +307,21 @@ fun StationBrowserScreen(
|
||||||
},
|
},
|
||||||
onToggleFavorite = { viewModel.toggleFavorite(station) },
|
onToggleFavorite = { viewModel.toggleFavorite(station) },
|
||||||
onRename = { renameTarget = station },
|
onRename = { renameTarget = station },
|
||||||
onDelete = { viewModel.removeCustomStation(station.id) },
|
onDelete = { viewModel.removeStation(station.id) },
|
||||||
|
wavlakeEnabled = viewModel.isWavlakeFeaturing(station.id).also { wavlakeVersion },
|
||||||
|
onToggleWavlake = { viewModel.toggleWavlakeFeaturing(station.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { Spacer(modifier = Modifier.height(8.dp)) }
|
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
stationsByGenre.forEach { (genre, stations) ->
|
visibleGenres.forEach { (genre, stations) ->
|
||||||
item {
|
item(key = "genre-$genre") {
|
||||||
GenreHeader(genre = genre, count = stations.size)
|
GenreHeader(genre = genre, count = stations.size)
|
||||||
}
|
}
|
||||||
items(stations, key = { it.id }) { station ->
|
items(stations, key = { it.id }, contentType = { "station" }) { station ->
|
||||||
StationCard(
|
StationCard(
|
||||||
station = station,
|
station = station,
|
||||||
isCustom = viewModel.isCustomStation(station.id),
|
|
||||||
isFavorite = viewModel.isFavorite(station.id),
|
isFavorite = viewModel.isFavorite(station.id),
|
||||||
isPlaying = playingUrl == station.url,
|
isPlaying = playingUrl == station.url,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
@ -215,8 +331,10 @@ fun StationBrowserScreen(
|
||||||
onToggleFavorite = { viewModel.toggleFavorite(station) },
|
onToggleFavorite = { viewModel.toggleFavorite(station) },
|
||||||
onRename = { renameTarget = station },
|
onRename = { renameTarget = station },
|
||||||
onDelete = {
|
onDelete = {
|
||||||
viewModel.removeCustomStation(station.id)
|
viewModel.removeStation(station.id)
|
||||||
},
|
},
|
||||||
|
wavlakeEnabled = viewModel.isWavlakeFeaturing(station.id).also { wavlakeVersion },
|
||||||
|
onToggleWavlake = { viewModel.toggleWavlakeFeaturing(station.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
|
|
@ -224,6 +342,39 @@ fun StationBrowserScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Restore point for deleted built-ins ──
|
||||||
|
if (searchQuery.isBlank() && hiddenIds.isNotEmpty()) {
|
||||||
|
item(key = "restore-hidden") {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = { viewModel.restoreHiddenStations() }) {
|
||||||
|
Text(
|
||||||
|
if (hiddenIds.size == 1) "Restore 1 hidden station"
|
||||||
|
else "Restore ${hiddenIds.size} hidden stations",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty filter state: no built-in matches ──
|
||||||
|
if (searchQuery.isNotBlank() && stationsByGenre.isEmpty() &&
|
||||||
|
directoryState is DirectoryState.Idle
|
||||||
|
) {
|
||||||
|
item(key = "no-match") {
|
||||||
|
Text(
|
||||||
|
text = "No stations match “$searchQuery”.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = LocalTokens.current.textSecondary,
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Directory results (radio-browser.info) ──
|
// ── Directory results (radio-browser.info) ──
|
||||||
when (val ds = directoryState) {
|
when (val ds = directoryState) {
|
||||||
DirectoryState.Idle -> Unit
|
DirectoryState.Idle -> Unit
|
||||||
|
|
@ -258,10 +409,9 @@ fun StationBrowserScreen(
|
||||||
is DirectoryState.Results -> {
|
is DirectoryState.Results -> {
|
||||||
if (ds.stations.isNotEmpty()) {
|
if (ds.stations.isNotEmpty()) {
|
||||||
item { DirectorySectionHeader("From the directory") }
|
item { DirectorySectionHeader("From the directory") }
|
||||||
items(ds.stations, key = { "rb-${it.id}" }) { station ->
|
items(ds.stations, key = { "rb-${it.id}" }, contentType = { "station" }) { station ->
|
||||||
StationCard(
|
StationCard(
|
||||||
station = station,
|
station = station,
|
||||||
isCustom = false,
|
|
||||||
isFavorite = viewModel.isFavorite(station.id),
|
isFavorite = viewModel.isFavorite(station.id),
|
||||||
isPlaying = playingUrl == station.url,
|
isPlaying = playingUrl == station.url,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
@ -288,9 +438,110 @@ fun StationBrowserScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Artists on Wavlake (same query, independent backend) ──
|
||||||
|
val waArtists = wavlakeArtists
|
||||||
|
if (waArtists is WavlakeArtistSearch.Results) {
|
||||||
|
item(key = "wl-header") { DirectorySectionHeader("Artists on Wavlake") }
|
||||||
|
items(
|
||||||
|
waArtists.artists,
|
||||||
|
key = { "wl-${it.id}" },
|
||||||
|
contentType = { "artist" },
|
||||||
|
) { artist ->
|
||||||
|
com.rada.ui.discover.ArtistRow(
|
||||||
|
name = artist.name,
|
||||||
|
genre = artist.genre,
|
||||||
|
imageUrl = artist.imageUrl,
|
||||||
|
onClick = { wavlakeArtistDialog = artist },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (waArtists is WavlakeArtistSearch.Loading) {
|
||||||
|
item(key = "wl-loading") {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = LocalTokens.current.accentText,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
text = "Searching Wavlake artists…",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = LocalTokens.current.textSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wavlakeArtistDialog?.let { artist ->
|
||||||
|
WavlakeArtistDialog(
|
||||||
|
artist = artist,
|
||||||
|
viewModel = wavlakeVm,
|
||||||
|
onDismiss = {
|
||||||
|
wavlakeVm.closeArtist()
|
||||||
|
wavlakeArtistDialog = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Composable
|
||||||
|
private fun WavlakeArtistDialog(
|
||||||
|
artist: com.rada.domain.model.WavlakeArtist,
|
||||||
|
viewModel: com.rada.ui.discover.WavlakeViewModel,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
androidx.compose.runtime.LaunchedEffect(artist.id) {
|
||||||
|
viewModel.openArtist(artist.id, artist.name)
|
||||||
|
}
|
||||||
|
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = LocalTokens.current.panel),
|
||||||
|
shape = RoundedCornerShape(SatsAmpGlass.rCard),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(0.85f),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 20.dp, end = 8.dp, top = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = artist.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = LocalTokens.current.text,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
IconButton(onClick = onDismiss, modifier = Modifier.size(48.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Close",
|
||||||
|
tint = LocalTokens.current.textSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
com.rada.ui.discover.ArtistDetailView(
|
||||||
|
artistId = artist.id,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
viewModel = viewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -430,11 +681,75 @@ private fun RenameStationDialog(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun GenreHeader(genre: String, count: Int) {
|
private fun NowPlayingHero(
|
||||||
val colorInt = genreColors.entries.find { (key, _) ->
|
station: Station,
|
||||||
genre.contains(key, ignoreCase = true)
|
onOpenPlayer: () -> Unit,
|
||||||
}?.value ?: 0xFF7C3AED
|
) {
|
||||||
|
val tokens = LocalTokens.current
|
||||||
|
Card(
|
||||||
|
onClick = onOpenPlayer,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
|
||||||
|
shape = RoundedCornerShape(SatsAmpGlass.rCard),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(SatsAmpGradients.accentBrush())
|
||||||
|
.padding(14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(52.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(tokens.accentOnFill.copy(alpha = 0.22f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
EqualizerBars(active = true, color = tokens.accentOnFill)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(14.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "Now Playing",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = tokens.accentOnFill.copy(alpha = 0.85f),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = station.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = tokens.accentOnFill,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (station.genre.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = station.genre,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = tokens.accentOnFill.copy(alpha = 0.85f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
|
contentDescription = "Open player",
|
||||||
|
tint = tokens.accentOnFill.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GenreHeader(genre: String, count: Int) {
|
||||||
|
val tokens = LocalTokens.current
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -445,21 +760,21 @@ private fun GenreHeader(genre: String, count: Int) {
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(8.dp)
|
.size(8.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(androidx.compose.ui.graphics.Color(colorInt)),
|
.background(tokens.accentFill),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(10.dp))
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
Text(
|
Text(
|
||||||
text = genre.uppercase(),
|
text = genre.uppercase(),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = androidx.compose.ui.graphics.Color(colorInt),
|
color = tokens.accentText,
|
||||||
letterSpacing = androidx.compose.ui.unit.TextUnit.Unspecified,
|
letterSpacing = androidx.compose.ui.unit.TextUnit.Unspecified,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "$count",
|
text = "$count",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = LocalTokens.current.textSecondary,
|
color = tokens.textSecondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -467,7 +782,6 @@ private fun GenreHeader(genre: String, count: Int) {
|
||||||
@Composable
|
@Composable
|
||||||
private fun StationCard(
|
private fun StationCard(
|
||||||
station: Station,
|
station: Station,
|
||||||
isCustom: Boolean,
|
|
||||||
isFavorite: Boolean,
|
isFavorite: Boolean,
|
||||||
isPlaying: Boolean,
|
isPlaying: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
|
@ -475,40 +789,49 @@ private fun StationCard(
|
||||||
onRename: () -> Unit,
|
onRename: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onAdd: (() -> Unit)? = null,
|
onAdd: (() -> Unit)? = null,
|
||||||
|
wavlakeEnabled: Boolean = false,
|
||||||
|
onToggleWavlake: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val tokens = LocalTokens.current
|
val tokens = LocalTokens.current
|
||||||
Card(
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp)
|
.padding(horizontal = 12.dp),
|
||||||
.clickable(onClick = onClick),
|
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = if (isPlaying) tokens.accentSoft else tokens.glassFillStrong,
|
containerColor = if (isPlaying) tokens.accentSoft else tokens.glassFillStrong,
|
||||||
),
|
),
|
||||||
border = if (isPlaying) {
|
border = if (isPlaying) {
|
||||||
BorderStroke(1.dp, tokens.accentFill.copy(alpha = 0.6f))
|
BorderStroke(1.dp, tokens.accentFill.copy(alpha = 0.6f))
|
||||||
} else null,
|
} else null,
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(SatsAmpGlass.rInput),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(tokens.accentFill.copy(alpha = 0.15f)),
|
.background(
|
||||||
|
if (isPlaying) tokens.accentFill
|
||||||
|
else tokens.accentFill.copy(alpha = 0.15f),
|
||||||
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (isPlaying) {
|
||||||
if (isPlaying) Icons.Default.PlayArrow else Icons.Default.Radio,
|
EqualizerBars(active = true, color = tokens.accentOnFill)
|
||||||
contentDescription = null,
|
} else {
|
||||||
tint = tokens.accentText,
|
Icon(
|
||||||
modifier = Modifier.size(20.dp),
|
Icons.Default.PlayArrow,
|
||||||
)
|
contentDescription = null,
|
||||||
|
tint = tokens.accentText,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(14.dp))
|
Spacer(modifier = Modifier.width(14.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
|
@ -531,42 +854,85 @@ private fun StationCard(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onToggleFavorite, modifier = Modifier.size(40.dp)) {
|
IconButton(
|
||||||
|
onClick = onToggleFavorite,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
|
imageVector = if (isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
|
||||||
contentDescription = if (isFavorite) "Unstar" else "Star",
|
contentDescription = if (isFavorite) "Unstar ${station.name}" else "Star ${station.name}",
|
||||||
tint = if (isFavorite) tokens.accentFill else tokens.textSecondary,
|
tint = if (isFavorite) tokens.accentFill else tokens.textSecondary,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Directory stations: offer saving into the local list.
|
// Directory stations: offer saving into the local list.
|
||||||
if (onAdd != null) {
|
if (onAdd != null) {
|
||||||
IconButton(onClick = onAdd, modifier = Modifier.size(40.dp)) {
|
IconButton(
|
||||||
|
onClick = onAdd,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Add,
|
Icons.Default.Add,
|
||||||
contentDescription = "Save to stations",
|
contentDescription = "Save ${station.name} to stations",
|
||||||
tint = tokens.textSecondary,
|
tint = tokens.textSecondary,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
IconButton(onClick = onRename, modifier = Modifier.size(40.dp)) {
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
Icon(
|
Box {
|
||||||
Icons.Default.Edit,
|
IconButton(
|
||||||
contentDescription = "Rename",
|
onClick = { showMenu = true },
|
||||||
tint = tokens.textSecondary,
|
modifier = Modifier.size(48.dp),
|
||||||
modifier = Modifier.size(20.dp),
|
) {
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isCustom) {
|
|
||||||
IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Delete,
|
Icons.Default.MoreVert,
|
||||||
contentDescription = "Remove",
|
contentDescription = "More actions for ${station.name}",
|
||||||
tint = tokens.textSecondary,
|
tint = tokens.textSecondary,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMenu,
|
||||||
|
onDismissRequest = { showMenu = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Rename") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = null)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onRename()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Delete") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = null)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onDelete()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (onToggleWavlake != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Wavlake picks") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
if (wavlakeEnabled) Icons.Default.Check
|
||||||
|
else Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onToggleWavlake()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.rada.data.repository.RadioBrowserRepository
|
import com.rada.data.repository.RadioBrowserRepository
|
||||||
import com.rada.data.repository.StationRepository
|
import com.rada.data.repository.StationRepository
|
||||||
|
import com.rada.data.wavlake.WavlakeRepository
|
||||||
|
import com.rada.data.wavlake.WavlakeResult
|
||||||
|
import com.rada.data.wavlake.WavlakeSearchHit
|
||||||
import com.rada.domain.model.Station
|
import com.rada.domain.model.Station
|
||||||
import com.rada.player.PlaybackManager
|
import com.rada.player.PlaybackManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
|
@ -20,6 +23,13 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** Wavlake artist search state (Stations search also finds artists). */
|
||||||
|
sealed interface WavlakeArtistSearch {
|
||||||
|
data object Idle : WavlakeArtistSearch
|
||||||
|
data object Loading : WavlakeArtistSearch
|
||||||
|
data class Results(val artists: List<com.rada.domain.model.WavlakeArtist>) : WavlakeArtistSearch
|
||||||
|
data class Error(val message: String) : WavlakeArtistSearch
|
||||||
|
}
|
||||||
/** Directory (radio-browser.info) search state. */
|
/** Directory (radio-browser.info) search state. */
|
||||||
sealed interface DirectoryState {
|
sealed interface DirectoryState {
|
||||||
data object Idle : DirectoryState
|
data object Idle : DirectoryState
|
||||||
|
|
@ -34,6 +44,7 @@ class StationBrowserViewModel @Inject constructor(
|
||||||
private val stationRepository: StationRepository,
|
private val stationRepository: StationRepository,
|
||||||
private val radioBrowserRepository: RadioBrowserRepository,
|
private val radioBrowserRepository: RadioBrowserRepository,
|
||||||
private val playbackManager: PlaybackManager,
|
private val playbackManager: PlaybackManager,
|
||||||
|
private val wavlakeRepository: WavlakeRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _allStations = MutableStateFlow(stationRepository.getAll())
|
private val _allStations = MutableStateFlow(stationRepository.getAll())
|
||||||
|
|
@ -43,36 +54,46 @@ class StationBrowserViewModel @Inject constructor(
|
||||||
|
|
||||||
val favorites: StateFlow<List<Station>> = stationRepository.favorites
|
val favorites: StateFlow<List<Station>> = stationRepository.favorites
|
||||||
|
|
||||||
|
val hiddenIds: StateFlow<Set<String>> = stationRepository.hiddenIds
|
||||||
|
|
||||||
|
/** One-shot delete notice, consumed by the Undo snackbar. */
|
||||||
|
private val _notice = MutableStateFlow<String?>(null)
|
||||||
|
val notice: StateFlow<String?> = _notice.asStateFlow()
|
||||||
|
private var lastDeleted: Station? = null
|
||||||
|
|
||||||
/** URL of whatever is playing now, so the list can mark the active row. */
|
/** URL of whatever is playing now, so the list can mark the active row. */
|
||||||
val playingUrl: StateFlow<String?> = playbackManager.currentStationUrlFlow
|
val playingUrl: StateFlow<String?> = playbackManager.currentStationUrlFlow
|
||||||
|
|
||||||
val stations: StateFlow<List<Station>> = combine(_allStations, _searchQuery) { list, query ->
|
val stationsByGenre: StateFlow<Map<String, List<Station>>> =
|
||||||
val filtered = if (query.isBlank()) list
|
combine(_allStations, _searchQuery, favorites) { list, query, favs ->
|
||||||
else list.filter {
|
// Starred stations move to the pinned top section, so they leave
|
||||||
it.name.contains(query, ignoreCase = true) ||
|
// their genre group instead of appearing twice.
|
||||||
it.genre.contains(query, ignoreCase = true) ||
|
val favIds = favs.map { it.id }.toSet()
|
||||||
it.url.contains(query, ignoreCase = true)
|
val base = if (query.isBlank()) {
|
||||||
}
|
list
|
||||||
filtered.sortedBy { it.name.lowercase() }
|
} else {
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), stationRepository.getAll())
|
list.filter {
|
||||||
|
it.name.contains(query, ignoreCase = true) ||
|
||||||
val stationsByGenre: StateFlow<Map<String, List<Station>>> = combine(_allStations, _searchQuery) { list, query ->
|
it.genre.contains(query, ignoreCase = true) ||
|
||||||
val filtered = if (query.isBlank()) list
|
it.url.contains(query, ignoreCase = true)
|
||||||
else list.filter {
|
}
|
||||||
it.name.contains(query, ignoreCase = true) ||
|
}
|
||||||
it.genre.contains(query, ignoreCase = true) ||
|
// Curated order: DefaultStations order is the display order (not
|
||||||
it.url.contains(query, ignoreCase = true)
|
// alphabetical), and groups appear in first-seen order so the
|
||||||
}
|
// playlist reads Lofi → … → NCS Chill, Media Monarchy first.
|
||||||
filtered.sortedBy { it.name.lowercase() }
|
base.filter { it.id !in favIds }
|
||||||
.groupBy { it.genre.split(" / ").first().ifEmpty { "Other" } }
|
.groupBy { it.genre.split(" / ").first().ifEmpty { "Other" } }
|
||||||
.toSortedMap(compareBy { it.lowercase() })
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
|
|
||||||
|
|
||||||
// ── Directory search (radio-browser.info, ~58k stations) ───────────
|
// ── Directory search (radio-browser.info, ~58k stations) ───────────
|
||||||
|
|
||||||
private val _directoryState = MutableStateFlow<DirectoryState>(DirectoryState.Idle)
|
private val _directoryState = MutableStateFlow<DirectoryState>(DirectoryState.Idle)
|
||||||
val directoryState: StateFlow<DirectoryState> = _directoryState.asStateFlow()
|
val directoryState: StateFlow<DirectoryState> = _directoryState.asStateFlow()
|
||||||
|
|
||||||
|
private val _wavlakeArtists =
|
||||||
|
MutableStateFlow<WavlakeArtistSearch>(WavlakeArtistSearch.Idle)
|
||||||
|
val wavlakeArtists: StateFlow<WavlakeArtistSearch> = _wavlakeArtists.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Debounced: search the wider directory only once typing settles.
|
// Debounced: search the wider directory only once typing settles.
|
||||||
_searchQuery
|
_searchQuery
|
||||||
|
|
@ -93,6 +114,37 @@ class StationBrowserViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.launchIn(viewModelScope)
|
.launchIn(viewModelScope)
|
||||||
|
|
||||||
|
// The same query also searches Wavlake artists on its own debounce,
|
||||||
|
// so one slow backend never blocks the other. Misses stay silent
|
||||||
|
// (Idle) — stations remain the primary results.
|
||||||
|
_searchQuery
|
||||||
|
.debounce(550)
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach { query ->
|
||||||
|
val q = query.trim()
|
||||||
|
if (q.length < 2) {
|
||||||
|
_wavlakeArtists.value = WavlakeArtistSearch.Idle
|
||||||
|
return@onEach
|
||||||
|
}
|
||||||
|
_wavlakeArtists.value = WavlakeArtistSearch.Loading
|
||||||
|
_wavlakeArtists.value = try {
|
||||||
|
when (val r = wavlakeRepository.search(q)) {
|
||||||
|
is WavlakeResult.Ok -> {
|
||||||
|
val artists = r.value
|
||||||
|
.filterIsInstance<WavlakeSearchHit.Artist>()
|
||||||
|
.map { it.artist }
|
||||||
|
.distinctBy { it.id }
|
||||||
|
if (artists.isEmpty()) WavlakeArtistSearch.Idle
|
||||||
|
else WavlakeArtistSearch.Results(artists)
|
||||||
|
}
|
||||||
|
is WavlakeResult.Failed -> WavlakeArtistSearch.Idle
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
WavlakeArtistSearch.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun search(query: String) {
|
fun search(query: String) {
|
||||||
|
|
@ -132,9 +184,32 @@ class StationBrowserViewModel @Inject constructor(
|
||||||
_allStations.value = stationRepository.getAll()
|
_allStations.value = stationRepository.getAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeCustomStation(id: String) {
|
/** Deletes any station (built-ins are hidden, customs removed) with Undo. */
|
||||||
|
fun removeStation(id: String) {
|
||||||
|
val station = stationRepository.getById(id) ?: return
|
||||||
|
lastDeleted = station
|
||||||
stationRepository.removeStation(id)
|
stationRepository.removeStation(id)
|
||||||
_allStations.value = stationRepository.getAll()
|
_allStations.value = stationRepository.getAll()
|
||||||
|
_notice.value = "Deleted “${station.name}”"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun undoDelete() {
|
||||||
|
val station = lastDeleted ?: return
|
||||||
|
lastDeleted = null
|
||||||
|
stationRepository.unhideStation(station.id)
|
||||||
|
if (stationRepository.getById(station.id) == null) {
|
||||||
|
stationRepository.addStation(station)
|
||||||
|
}
|
||||||
|
_allStations.value = stationRepository.getAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeNotice() {
|
||||||
|
_notice.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restoreHiddenStations() {
|
||||||
|
stationRepository.restoreHiddenStations()
|
||||||
|
_allStations.value = stationRepository.getAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun renameStation(id: String, newName: String) {
|
fun renameStation(id: String, newName: String) {
|
||||||
|
|
@ -155,4 +230,15 @@ class StationBrowserViewModel @Inject constructor(
|
||||||
fun isCustomStation(id: String): Boolean {
|
fun isCustomStation(id: String): Boolean {
|
||||||
return stationRepository.isCustomStation(id)
|
return stationRepository.isCustomStation(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bumps to refresh per-station Wavlake featuring toggles in the UI. */
|
||||||
|
private val _wavlakeVersion = MutableStateFlow(0)
|
||||||
|
val wavlakeVersion: StateFlow<Int> = _wavlakeVersion.asStateFlow()
|
||||||
|
|
||||||
|
fun isWavlakeFeaturing(id: String): Boolean = wavlakeRepository.isStationEnabled(id)
|
||||||
|
|
||||||
|
fun toggleWavlakeFeaturing(id: String) {
|
||||||
|
wavlakeRepository.setStationEnabled(id, !wavlakeRepository.isStationEnabled(id))
|
||||||
|
_wavlakeVersion.value += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.rada.ui.streams
|
package com.rada.ui.streams
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -56,6 +55,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.rada.domain.model.StreamEntry
|
import com.rada.domain.model.StreamEntry
|
||||||
import com.rada.ui.components.AppHomeButton
|
import com.rada.ui.components.AppHomeButton
|
||||||
|
import com.rada.ui.components.EqualizerBars
|
||||||
import com.rada.ui.theme.LocalTokens
|
import com.rada.ui.theme.LocalTokens
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -211,7 +211,7 @@ fun StreamsScreen(
|
||||||
item(key = "header-$group") {
|
item(key = "header-$group") {
|
||||||
GroupHeader(group = group, count = entries.size)
|
GroupHeader(group = group, count = entries.size)
|
||||||
}
|
}
|
||||||
items(entries, key = { it.id }) { entry ->
|
items(entries, key = { it.id }, contentType = { "stream" }) { entry ->
|
||||||
StreamCard(
|
StreamCard(
|
||||||
entry = entry,
|
entry = entry,
|
||||||
isPlaying = currentStreamUrl == entry.url,
|
isPlaying = currentStreamUrl == entry.url,
|
||||||
|
|
@ -393,10 +393,10 @@ private fun StreamCard(
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp)
|
.padding(horizontal = 12.dp),
|
||||||
.clickable(onClick = onClick),
|
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = if (isPlaying) LocalTokens.current.accentFill.copy(alpha = 0.12f) else LocalTokens.current.glassFillStrong,
|
containerColor = if (isPlaying) LocalTokens.current.accentFill.copy(alpha = 0.12f) else LocalTokens.current.glassFillStrong,
|
||||||
),
|
),
|
||||||
|
|
@ -415,12 +415,20 @@ private fun StreamCard(
|
||||||
.background(if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.accentFill.copy(alpha = 0.15f)),
|
.background(if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.accentFill.copy(alpha = 0.15f)),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (isPlaying) {
|
||||||
if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow,
|
EqualizerBars(
|
||||||
contentDescription = null,
|
active = true,
|
||||||
tint = if (isPlaying) Color.White else LocalTokens.current.accentText,
|
color = LocalTokens.current.accentOnFill,
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = LocalTokens.current.accentText,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(14.dp))
|
Spacer(modifier = Modifier.width(14.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
|
@ -441,7 +449,7 @@ private fun StreamCard(
|
||||||
IconButton(onClick = onRename) {
|
IconButton(onClick = onRename) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Edit,
|
Icons.Default.Edit,
|
||||||
contentDescription = "Rename",
|
contentDescription = "Rename ${entry.name}",
|
||||||
tint = LocalTokens.current.textSecondary,
|
tint = LocalTokens.current.textSecondary,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
|
|
@ -449,7 +457,7 @@ private fun StreamCard(
|
||||||
IconButton(onClick = onDelete) {
|
IconButton(onClick = onDelete) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Delete,
|
Icons.Default.Delete,
|
||||||
contentDescription = "Remove",
|
contentDescription = "Remove ${entry.name}",
|
||||||
tint = LocalTokens.current.textSecondary,
|
tint = LocalTokens.current.textSecondary,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,10 @@ internal val AccentSwatches = mapOf(
|
||||||
* the whole UI recomposes live through LocalTokens.
|
* the whole UI recomposes live through LocalTokens.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AppearanceDialog(onDismiss: () -> Unit) {
|
fun AppearanceDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onOpenDecentralized: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
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()
|
||||||
|
|
@ -141,8 +144,15 @@ fun AppearanceDialog(onDismiss: () -> Unit) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = onDismiss) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Text("Done", color = tokens.accentText, fontWeight = FontWeight.Bold)
|
if (onOpenDecentralized != null) {
|
||||||
|
TextButton(onClick = onOpenDecentralized) {
|
||||||
|
Text("⚡ Nostr", color = tokens.accentText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Done", color = tokens.accentText, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,18 @@
|
||||||
package com.rada.ui.theme
|
package com.rada.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.animation.EnterTransition
|
import androidx.compose.animation.EnterTransition
|
||||||
import androidx.compose.animation.ExitTransition
|
import androidx.compose.animation.ExitTransition
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
import androidx.compose.animation.core.CubicBezierEasing
|
||||||
|
|
@ -61,24 +74,24 @@ fun hubExit(reduce: Boolean): ExitTransition =
|
||||||
// and pop back to the left, so direction teaches the hierarchy. Slide is a
|
// and pop back to the left, so direction teaches the hierarchy. Slide is a
|
||||||
// quarter-travel, not a full page wipe — fast screens stay legible mid-motion.
|
// quarter-travel, not a full page wipe — fast screens stay legible mid-motion.
|
||||||
|
|
||||||
fun detailEnter(reduce: Boolean): EnterTransition =
|
fun detailEnter(reduce: Boolean, forward: Boolean = true): EnterTransition =
|
||||||
if (reduce) fadeIn(tween(ExitFastMs))
|
if (reduce) fadeIn(tween(ExitFastMs))
|
||||||
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { it / 4 } +
|
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { if (forward) it / 4 else -it / 4 } +
|
||||||
fadeIn(tween(StateMs, easing = MotionArrival))
|
fadeIn(tween(StateMs, easing = MotionArrival))
|
||||||
|
|
||||||
fun detailExit(reduce: Boolean): ExitTransition =
|
fun detailExit(reduce: Boolean, forward: Boolean = true): ExitTransition =
|
||||||
if (reduce) fadeOut(tween(ExitFastMs))
|
if (reduce) fadeOut(tween(ExitFastMs))
|
||||||
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { -it / 8 } +
|
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { if (forward) -it / 8 else it / 8 } +
|
||||||
fadeOut(tween(ExitFastMs, easing = MotionExit))
|
fadeOut(tween(ExitFastMs, easing = MotionExit))
|
||||||
|
|
||||||
fun detailPopEnter(reduce: Boolean): EnterTransition =
|
fun detailPopEnter(reduce: Boolean, forward: Boolean = true): EnterTransition =
|
||||||
if (reduce) fadeIn(tween(ExitFastMs))
|
if (reduce) fadeIn(tween(ExitFastMs))
|
||||||
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { -it / 4 } +
|
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { if (forward) -it / 8 else it / 8 } +
|
||||||
fadeIn(tween(StateMs, easing = MotionArrival))
|
fadeIn(tween(StateMs, easing = MotionArrival))
|
||||||
|
|
||||||
fun detailPopExit(reduce: Boolean): ExitTransition =
|
fun detailPopExit(reduce: Boolean, forward: Boolean = true): ExitTransition =
|
||||||
if (reduce) fadeOut(tween(ExitFastMs))
|
if (reduce) fadeOut(tween(ExitFastMs))
|
||||||
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { it / 8 } +
|
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { if (forward) it / 4 else -it / 4 } +
|
||||||
fadeOut(tween(ExitFastMs, easing = MotionExit))
|
fadeOut(tween(ExitFastMs, easing = MotionExit))
|
||||||
|
|
||||||
// ── Now-playing bar ───────────────────────────────────────────────────────
|
// ── Now-playing bar ───────────────────────────────────────────────────────
|
||||||
|
|
@ -107,3 +120,62 @@ fun metadataExit(reduce: Boolean): ExitTransition =
|
||||||
|
|
||||||
/** Duration for press/feedback animations, zeroed under reduce motion. */
|
/** Duration for press/feedback animations, zeroed under reduce motion. */
|
||||||
fun feedbackDuration(reduce: Boolean): Int = if (reduce) 0 else FeedbackMs
|
fun feedbackDuration(reduce: Boolean): Int = if (reduce) 0 else FeedbackMs
|
||||||
|
|
||||||
|
// ── Press feedback ────────────────────────────────────────────────────────
|
||||||
|
// Taps should feel physical before navigation resolves: the row dips
|
||||||
|
// (scale 0.97) on press and springs back on release. Transform-only, so
|
||||||
|
// it's cheap; zeroed under Reduce Motion by the caller.
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
fun Modifier.pressDip(
|
||||||
|
interactionSource: MutableInteractionSource,
|
||||||
|
reduce: Boolean,
|
||||||
|
): Modifier = composed {
|
||||||
|
if (reduce) return@composed this
|
||||||
|
val pressed by interactionSource.collectIsPressedAsState()
|
||||||
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = if (pressed) 0.97f else 1f,
|
||||||
|
animationSpec = androidx.compose.animation.core.tween(
|
||||||
|
durationMillis = if (pressed) 90 else 160,
|
||||||
|
easing = if (pressed) MotionExit else MotionArrival,
|
||||||
|
),
|
||||||
|
label = "pressDip",
|
||||||
|
)
|
||||||
|
this.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dismiss-by-drag ───────────────────────────────────────────────────────
|
||||||
|
// A floating bar should answer to a downward drag: it follows the finger
|
||||||
|
// (translationY only) and dismisses past a threshold or on a downward fling;
|
||||||
|
// otherwise it springs back. Reduce Motion: no follow, threshold-only dismiss.
|
||||||
|
|
||||||
|
fun Modifier.verticalDragToDismiss(
|
||||||
|
thresholdFraction: Float = 0.5f,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
): Modifier = composed {
|
||||||
|
val reduce = LocalReduceMotion.current
|
||||||
|
var dragPx by remember { mutableFloatStateOf(0f) }
|
||||||
|
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||||
|
val maxDrag = with(density) { 120.dp.toPx() }
|
||||||
|
this
|
||||||
|
.graphicsLayer { translationY = if (reduce) 0f else dragPx }
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectVerticalDragGestures(
|
||||||
|
onDragEnd = {
|
||||||
|
if (dragPx > size.height * thresholdFraction) onDismiss()
|
||||||
|
dragPx = 0f
|
||||||
|
},
|
||||||
|
onDragCancel = { dragPx = 0f },
|
||||||
|
onVerticalDrag = { change, amount ->
|
||||||
|
if (amount > 0) {
|
||||||
|
change.consume()
|
||||||
|
dragPx = (dragPx + amount).coerceIn(0f, maxDrag)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
164
app/src/test/java/com/rada/data/wavlake/WavlakeLogicTest.kt
Normal file
164
app/src/test/java/com/rada/data/wavlake/WavlakeLogicTest.kt
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
package com.rada.data.wavlake
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
class WavlakeLogicTest {
|
||||||
|
|
||||||
|
private fun track(
|
||||||
|
id: String,
|
||||||
|
artistId: String = "a1",
|
||||||
|
audio: String? = "https://cdn.example/t.mp3",
|
||||||
|
) = com.rada.domain.model.WavlakeTrack(
|
||||||
|
id = id, title = "T $id", artistId = artistId,
|
||||||
|
artistName = "A $artistId", audioUrl = audio,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Normalization ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `track without id or title is dropped`() {
|
||||||
|
assertNull(normalizeWavlakeTrack(TrackDto(id = "", title = "x")))
|
||||||
|
assertNull(normalizeWavlakeTrack(TrackDto(id = "1", title = " ")))
|
||||||
|
assertNull(normalizeWavlakeTrack(TrackDto()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `track tolerates missing artwork duration genre`() {
|
||||||
|
val t = normalizeWavlakeTrack(TrackDto(id = "1", title = "Song"))!!
|
||||||
|
assertEquals("Unknown Artist", t.artistName)
|
||||||
|
assertNull(t.artworkUrl)
|
||||||
|
assertNull(t.durationMs)
|
||||||
|
assertNull(t.genre)
|
||||||
|
assertNull(t.audioUrl)
|
||||||
|
assertEquals("wavlake", t.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `track maps all swagger fields`() {
|
||||||
|
val t = normalizeWavlakeTrack(
|
||||||
|
TrackDto(
|
||||||
|
id = "t1", title = "S", artist = "A", artistId = "a",
|
||||||
|
albumArtUrl = "https://img/a.png", mediaUrl = "https://cdn/t.mp3",
|
||||||
|
duration = 180, genre = "Dance/EDM", url = "https://wavlake.com/t",
|
||||||
|
artistNpub = "npub1xyz",
|
||||||
|
),
|
||||||
|
)!!
|
||||||
|
assertEquals(180_000L, t.durationMs)
|
||||||
|
assertEquals(listOf("Dance/EDM"), t.genres)
|
||||||
|
assertEquals("https://cdn/t.mp3", t.audioUrl)
|
||||||
|
assertEquals("npub1xyz", t.artistNpubHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `artist without id is dropped, blank name defaulted`() {
|
||||||
|
assertNull(normalizeWavlakeArtist(ArtistDto()))
|
||||||
|
assertEquals("Unknown Artist", normalizeWavlakeArtist(ArtistDto(id = "a"))!!.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Genre matching ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `station genre split convention`() {
|
||||||
|
val (primary, secondary) = WavlakeGenreMatcher.splitStationGenres("Lofi / Hip-Hop")
|
||||||
|
assertEquals("Lofi", primary)
|
||||||
|
assertEquals(listOf("Hip-Hop"), secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lofi maps to related exact names`() {
|
||||||
|
val genres = WavlakeGenreMatcher.compatibleGenres("Lofi", emptyList())
|
||||||
|
assertTrue(genres.contains("Downtempo"))
|
||||||
|
assertTrue(genres.none { it == "Lofi" }) // never invent names
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `edm keeps exact Dance-EDM plus subs`() {
|
||||||
|
val genres = WavlakeGenreMatcher.compatibleGenres("EDM", emptyList())
|
||||||
|
assertTrue(genres.contains("Dance/EDM"))
|
||||||
|
assertTrue(genres.contains("House"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `talk matches nothing`() {
|
||||||
|
assertTrue(WavlakeGenreMatcher.compatibleGenres("Talk", emptyList()).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exact mode only`() {
|
||||||
|
val genres = WavlakeGenreMatcher.compatibleGenres("Pop", emptyList(), MatchingMode.EXACT)
|
||||||
|
assertEquals(listOf("Pop"), genres)
|
||||||
|
assertTrue(
|
||||||
|
WavlakeGenreMatcher.compatibleGenres("Lofi", emptyList(), MatchingMode.EXACT).isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selector ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private val settings = defaultWavlakeStationSettings.copy(
|
||||||
|
featureProbability = 1.0,
|
||||||
|
maxFeaturedTracksPerHour = 3,
|
||||||
|
maxFeaturedTracksPerQueue = 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disabled or capped selects nothing`() {
|
||||||
|
val pool = listOf(track("t1"))
|
||||||
|
assertTrue(
|
||||||
|
FeatureSelector.select(
|
||||||
|
FeatureSelection(pool, settings.copy(enabled = false), random = Random(1)),
|
||||||
|
).isEmpty(),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
FeatureSelector.select(
|
||||||
|
FeatureSelection(pool, settings, featuredThisHour = 3, random = Random(1)),
|
||||||
|
).isEmpty(),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
FeatureSelector.select(
|
||||||
|
FeatureSelection(pool, settings.copy(featureProbability = 0.0), random = Random(1)),
|
||||||
|
).isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dedupes blocked and unplayable`() {
|
||||||
|
val pool = listOf(
|
||||||
|
track("t1", artistId = "blocked"),
|
||||||
|
track("t2", audio = " "),
|
||||||
|
track("t3"),
|
||||||
|
track("t3"),
|
||||||
|
)
|
||||||
|
val picked = FeatureSelector.select(
|
||||||
|
FeatureSelection(
|
||||||
|
pool, settings,
|
||||||
|
excludedTrackIds = setOf("t3"),
|
||||||
|
blockedArtistIds = setOf("blocked"),
|
||||||
|
random = Random(1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(picked.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fresh artists win over recent, cap respected`() {
|
||||||
|
val pool = listOf(
|
||||||
|
track("old", artistId = "a1"),
|
||||||
|
track("new1", artistId = "a2"),
|
||||||
|
track("new2", artistId = "a3"),
|
||||||
|
)
|
||||||
|
val picked = FeatureSelector.select(
|
||||||
|
FeatureSelection(
|
||||||
|
pool,
|
||||||
|
settings.copy(maxFeaturedTracksPerQueue = 2),
|
||||||
|
recentArtistIds = setOf("a1"),
|
||||||
|
random = Random(7),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(2, picked.size)
|
||||||
|
assertTrue(picked.none { it.artistId == "a1" })
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/src/test/java/com/rada/data/wavlake/WavlakeZapTest.kt
Normal file
67
app/src/test/java/com/rada/data/wavlake/WavlakeZapTest.kt
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
package com.rada.data.wavlake
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Official track-zap protocol conformance (patterns decoded from
|
||||||
|
* wavlake/mobile, MIT — values below are independently computed).
|
||||||
|
*/
|
||||||
|
class WavlakeZapTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `uuid v5 matches RFC vectors`() {
|
||||||
|
// Well-known: v5(DNS, www.example.com).
|
||||||
|
assertEquals(
|
||||||
|
"2ed6657d-e927-568b-95e1-2665a8aea6a2",
|
||||||
|
WavlakeZap.uuidV5(UUID.fromString("6ba7b810-9dad-11d1-80b4-00c04fd430c8"), "www.example.com").toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `uuid v5 feed guid matches workstation python`() {
|
||||||
|
// Cross-checked with Python's uuid.uuid5.
|
||||||
|
assertEquals(
|
||||||
|
"3cd075e1-b31f-57f4-a37a-da0996e20ae1",
|
||||||
|
WavlakeZap.uuidV5(
|
||||||
|
WavlakeZap.FEED_NAMESPACE,
|
||||||
|
"wavlake.com/feed/music/7acbd8ef-15b7-4deb-9802-8b30035b0ec7",
|
||||||
|
).toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `zap request carries official tags`() {
|
||||||
|
val unsigned = WavlakeZap.buildZapRequest(
|
||||||
|
trackId = "81c45017-5d13-45fd-a751-c9483b579682",
|
||||||
|
albumId = "7acbd8ef-15b7-4deb-9802-8b30035b0ec7",
|
||||||
|
amountSats = 100,
|
||||||
|
comment = "test",
|
||||||
|
relays = listOf("wss://relay.damus.io"),
|
||||||
|
signerPubkeyHex = "00".repeat(32),
|
||||||
|
)
|
||||||
|
assertEquals(9734, unsigned.kind)
|
||||||
|
val tags = unsigned.tags
|
||||||
|
assertTrue(tags.contains(listOf("p", WavlakeZap.FEED_PUBKEY)))
|
||||||
|
assertTrue(tags.any { it.getOrNull(0) == "relays" && it.contains("wss://relay.wavlake.com/") })
|
||||||
|
assertTrue(tags.contains(listOf("amount", "100000")))
|
||||||
|
assertTrue(
|
||||||
|
tags.any {
|
||||||
|
it.getOrNull(0) == "a" &&
|
||||||
|
it.getOrNull(1) == "32123:${WavlakeZap.FEED_PUBKEY}:81c45017-5d13-45fd-a751-c9483b579682"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertTrue(tags.any { it.getOrNull(0) == "timestamp" })
|
||||||
|
assertTrue(tags.count { it.getOrNull(0) == "i" } == 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invoice sanity is strict mainnet-only`() {
|
||||||
|
assertTrue(WavlakeZap.isPlausibleInvoice("lnbc100n1p" + "x".repeat(30)))
|
||||||
|
assertTrue(!WavlakeZap.isPlausibleInvoice("lntb100n1p" + "x".repeat(30)))
|
||||||
|
assertTrue(!WavlakeZap.isPlausibleInvoice("not-an-invoice"))
|
||||||
|
assertTrue(!WavlakeZap.isPlausibleInvoice("lnbc short"))
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/src/test/java/com/rada/nostr/NostrProtocolTest.kt
Normal file
91
app/src/test/java/com/rada/nostr/NostrProtocolTest.kt
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
package com.rada.nostr
|
||||||
|
|
||||||
|
import fr.acinq.secp256k1.Secp256k1
|
||||||
|
import fr.acinq.secp256k1.jni.NativeSecp256k1JvmLoader
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protocol conformance tests against official spec vectors.
|
||||||
|
* Runs on the JVM via secp256k1-kmp-jni-jvm (testImplementation).
|
||||||
|
*/
|
||||||
|
class NostrProtocolTest {
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Load the JVM native binding for unit tests only.
|
||||||
|
NativeSecp256k1JvmLoader.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `NIP-19 npub encodes known vector`() {
|
||||||
|
val pub = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"
|
||||||
|
assertEquals(
|
||||||
|
"npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg",
|
||||||
|
Nip19.npub(pub),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `NIP-19 nsec encodes and decodes known vector`() {
|
||||||
|
val priv = "67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa"
|
||||||
|
val enc = Nip19.nsec(priv)
|
||||||
|
assertEquals("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", enc)
|
||||||
|
val dec = Nip19.decode(enc)
|
||||||
|
assertNotNull(dec)
|
||||||
|
val (hrp, bytes) = dec!!
|
||||||
|
assertEquals("nsec", hrp)
|
||||||
|
assertEquals(priv, bytes.toHex())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `schnorr sign verifies with BIP-340 semantics`() {
|
||||||
|
// sk=3 -> x-only pubkey F9308A... (BIP-340 vector 0)
|
||||||
|
val sk = ByteArray(32).also { it[31] = 3 }
|
||||||
|
val pub = Secp256k1.get().pubkeyCreate(sk).copyOfRange(1, 33)
|
||||||
|
assertEquals(
|
||||||
|
"f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",
|
||||||
|
pub.toHex(),
|
||||||
|
)
|
||||||
|
val msg = ByteArray(32)
|
||||||
|
val sig = Secp256k1.get().signSchnorr(msg, sk, null)
|
||||||
|
assertEquals(64, sig.size)
|
||||||
|
assertTrue(Secp256k1.get().verifySchnorr(sig, msg, pub))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `event id is sha256 of NIP-01 serialization`() {
|
||||||
|
val unsigned = UnsignedEvent(
|
||||||
|
pubkey = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",
|
||||||
|
createdAt = 1700000000,
|
||||||
|
kind = 1,
|
||||||
|
tags = listOf(listOf("e", "abc", "wss://relay.x"), listOf("d", "t1")),
|
||||||
|
content = "hello world",
|
||||||
|
)
|
||||||
|
// Canonical serialization per NIP-01 (no whitespace, fixed order).
|
||||||
|
assertEquals(
|
||||||
|
"""[0,"f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",1700000000,1,[["e","abc","wss://relay.x"],["d","t1"]],"hello world"]""",
|
||||||
|
unsigned.serialized(),
|
||||||
|
)
|
||||||
|
// Sign with sk=3 and verify() must pass end to end.
|
||||||
|
val ev = unsigned.sign { digest -> Secp256k1.get().signSchnorr(digest, ByteArray(32).also { it[31] = 3 }, null).toHex() }
|
||||||
|
assertNotNull(ev)
|
||||||
|
assertTrue("event self-verification (id hash + schnorr)", ev!!.verify())
|
||||||
|
// Tampering must break verification.
|
||||||
|
assertFalse(ev.copy(content = "tampered").verify())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nip-04 roundtrip between two identities`() {
|
||||||
|
val skA = ByteArray(32).also { it[31] = 11 }
|
||||||
|
val skB = ByteArray(32).also { it[31] = 22 }
|
||||||
|
val pubA = Secp256k1.get().pubkeyCreate(skA).copyOfRange(1, 33).toHex()
|
||||||
|
val pubB = Secp256k1.get().pubkeyCreate(skB).copyOfRange(1, 33).toHex()
|
||||||
|
val msg = "zap 21 sats ⚡"
|
||||||
|
val enc = Nip04.encrypt(skA, pubB, msg)
|
||||||
|
org.junit.Assert.assertEquals(msg, Nip04.decrypt(skB, pubA, enc))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertFalse(b: Boolean) = org.junit.Assert.assertFalse(b)
|
||||||
|
}
|
||||||
58
app/src/test/java/com/rada/payments/LightningAddressTest.kt
Normal file
58
app/src/test/java/com/rada/payments/LightningAddressTest.kt
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
package com.rada.payments
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spec-case coverage for the strict zap-symbol rule: ONLY a well-formed
|
||||||
|
* Lightning Address enables [hasValidLightningAddress].
|
||||||
|
*/
|
||||||
|
class LightningAddressTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid address passes`() {
|
||||||
|
assertTrue(hasValidLightningAddress("artist@example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing null and blank fail`() {
|
||||||
|
assertFalse(hasValidLightningAddress(null))
|
||||||
|
assertFalse(hasValidLightningAddress(""))
|
||||||
|
assertFalse(hasValidLightningAddress(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `wrong at-count fails`() {
|
||||||
|
assertFalse(hasValidLightningAddress("artistexample.com"))
|
||||||
|
assertFalse(hasValidLightningAddress("a@b@c.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty user or domain fails`() {
|
||||||
|
assertFalse(hasValidLightningAddress("@example.com"))
|
||||||
|
assertFalse(hasValidLightningAddress("artist@"))
|
||||||
|
assertFalse(hasValidLightningAddress("artist@ "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `domain without dot or with space fails`() {
|
||||||
|
assertFalse(hasValidLightningAddress("artist@localhost"))
|
||||||
|
assertFalse(hasValidLightningAddress("artist@exam ple.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lnurl and keys are not addresses`() {
|
||||||
|
assertFalse(hasValidLightningAddress("LNURL1DP68GURN8PHKCMN9W..."))
|
||||||
|
assertFalse(hasValidLightningAddress("7e7e9c42a91bfef19fa9295fda1b72e0ebc1a4c1141673e2794234d86addf4e"))
|
||||||
|
assertFalse(hasValidLightningAddress("npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `trims and normalizes`() {
|
||||||
|
assertTrue(hasValidLightningAddress(" artist@example.com "))
|
||||||
|
assertEquals("artist@example.com", normalizedLightningAddress(" artist@example.com "))
|
||||||
|
assertEquals(null, normalizedLightningAddress("not-an-address"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue