Merge branch 'main' of https://github.com/vitorpamplona/amethyst into upstream-main

# Conflicts:
#	gradle/libs.versions.toml
#	quartz/build.gradle.kts
#	quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.android.kt
#	quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt
#	quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt
This commit is contained in:
KotlinGeekDev
2026-03-04 00:37:10 +01:00
581 changed files with 36083 additions and 4749 deletions
+672
View File
@@ -0,0 +1,672 @@
---
name: quartz-integration
description: Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects.
---
# Quartz Integration Guide
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.05.1` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
---
## 1. Gradle Setup
### Version Catalog (`libs.versions.toml`)
```toml
[versions]
quartz = "1.05.1"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
```
### `build.gradle.kts` (KMP project)
```kotlin
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.quartz)
}
}
}
```
### Android-only project
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.05.1")
}
```
### Transitive dependencies pulled in automatically
Quartz exposes these as `api` (you get them transitively):
| Dependency | Used for |
|-----------|----------|
| `fr.acinq.secp256k1:secp256k1-kmp-*` | Schnorr signing |
| `com.github.anthonynsimon:rfc3986-normalizer` | Relay URL normalization |
| `com.fasterxml.jackson.module:jackson-module-kotlin` | Event JSON parsing |
| `com.linkedin.urls:url-detector` | URL extraction from content |
For Android, add to `build.gradle.kts`:
```kotlin
android {
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
```
---
## 2. Key Concepts
### Core Types
```kotlin
typealias HexKey = String // 64-char hex string (pubkey, event id, sig)
typealias Kind = Int // Event kind number
typealias TagArray = Array<Array<String>>
```
### Event Anatomy
```kotlin
@Immutable
open class Event(
val id: HexKey, // SHA-256 of canonical JSON (64 hex chars)
val pubKey: HexKey, // Author public key (64 hex chars)
val createdAt: Long, // Unix timestamp (seconds)
val kind: Kind, // Event type
val tags: TagArray, // [["e","eventid"], ["p","pubkey"], ...]
val content: String,
val sig: HexKey, // Schnorr signature (128 hex chars)
)
```
### Kind Classification
```kotlin
// Regular events — stored by relays forever
val isRegular = kind in 1..9999
// Replaceable events — relay keeps only latest per (pubkey, kind)
val isReplaceable = kind == 0 || kind == 3 || kind in 10000..19999
// Addressable events — relay keeps latest per (pubkey, kind, d-tag)
val isAddressable = kind in 30000..39999
// Ephemeral events — relays don't persist
val isEphemeral = kind in 20000..29999
```
---
## 3. Key Management
### Generate a new keypair
```kotlin
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
// Generate fresh random keys
val keyPair = KeyPair()
// From existing private key bytes
val keyPair = KeyPair(privKey = myPrivKeyBytes)
// Read-only (public key only, cannot sign)
val keyPair = KeyPair(pubKey = myPubKeyBytes)
// Access
val pubKeyHex: String = keyPair.pubKey.toHexKey()
val privKeyHex: String? = keyPair.privKey?.toHexKey()
```
### Convert between formats
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
// ByteArray → hex
val hex = byteArray.toHexKey()
// hex → ByteArray
val bytes = HexKey.decodeHex(hex)
// Bech32 import (npub, nsec)
val parsed = Nip19Parser.uriToRoute("npub1abc...")
// or
val parsed = Nip19Parser.uriToRoute("nsec1abc...")
```
---
## 4. Signing Events
### `NostrSignerInternal` (local key, JVM + Android)
```kotlin
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
val keyPair = KeyPair()
val signer = NostrSignerInternal(keyPair)
// Sign any EventTemplate
val template = TextNoteEvent.build("Hello Nostr!")
val signedEvent: TextNoteEvent = signer.sign(template)
```
### `NostrSignerSync` (synchronous, for testing)
```kotlin
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
val signerSync = NostrSignerSync(keyPair)
val event = signerSync.sign<TextNoteEvent>(
createdAt = TimeUtils.now(),
kind = 1,
tags = emptyArray(),
content = "Hello!"
)
```
### NostrSigner interface (for custom signers)
```kotlin
abstract class NostrSigner(val pubKey: HexKey) {
abstract fun isWriteable(): Boolean
abstract suspend fun <T : Event> sign(createdAt: Long, kind: Int, tags: Array<Array<String>>, content: String): T
abstract suspend fun nip04Encrypt(plaintext: String, toPublicKey: HexKey): String
abstract suspend fun nip04Decrypt(ciphertext: String, fromPublicKey: HexKey): String
abstract suspend fun nip44Encrypt(plaintext: String, toPublicKey: HexKey): String
abstract suspend fun nip44Decrypt(ciphertext: String, fromPublicKey: HexKey): String
abstract suspend fun deriveKey(nonce: HexKey): HexKey
abstract fun hasForegroundSupport(): Boolean
// Convenience: auto-detects NIP-04 vs NIP-44 by ciphertext format
suspend fun decrypt(encryptedContent: String, fromPublicKey: HexKey): String
}
```
---
## 5. Creating Events
### Using typed event builders (recommended)
```kotlin
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
// Kind 1 — Text note
val template = TextNoteEvent.build("Hello Nostr!")
val event: TextNoteEvent = signer.sign(template)
// Kind 1 — Reply
val replyTemplate = TextNoteEvent.build(
note = "Interesting thread!",
replyingTo = originalEventHintBundle
)
// Kind 7 — Reaction
val reactionTemplate = ReactionEvent.build(
content = "+", // "+" = like, "-" = dislike, emoji = custom
originalNote = targetEvent
)
```
### Using low-level `Event.build` DSL
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.Event
val template = Event.build(
kind = 1,
content = "Hello world",
createdAt = TimeUtils.now()
) {
// TagArrayBuilder DSL
add(arrayOf("p", mentionedPubKey))
add(arrayOf("t", "nostr"))
add(arrayOf("subject", "Greeting"))
}
val event: Event = signer.sign(template)
```
### TagArrayBuilder DSL methods
```kotlin
// In the DSL lambda:
add(arrayOf("tagname", "value")) // append
addFirst(arrayOf("tagname", "value")) // prepend
addUnique(arrayOf("d", "my-slug")) // replace all tags with same name
addAll(listOf(arrayOf("t", "tag1"), ...)) // bulk add
remove("tagname") // remove all with this name
```
## 6. Relay Client Setup (JVM / Android)
The relay client requires an OkHttp WebSocket builder (available on JVM + Android).
### Minimal setup
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import okhttp3.OkHttpClient
// Build the WebSocket factory
val okHttpClient = OkHttpClient.Builder().build()
val wsBuilder = BasicOkHttpWebSocket.Builder { _ -> okHttpClient }
// Create client (manages its own CoroutineScope internally)
val nostrClient = NostrClient(websocketBuilder = wsBuilder)
nostrClient.connect()
```
### With custom OkHttpClient per relay
```kotlin
val wsBuilder = BasicOkHttpWebSocket.Builder { normalizedUrl ->
if (normalizedUrl.url.contains(".onion")) {
torEnabledOkHttpClient // Tor proxy for .onion relays
} else {
regularOkHttpClient
}
}
```
### With custom CoroutineScope
```kotlin
val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val nostrClient = NostrClient(wsBuilder, scope = appScope)
```
---
## 7. Subscribing to Events
### Normalize relay URLs first
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
// Returns NormalizedRelayUrl (wrapper with validated wss:// URL)
val relayUrl = RelayUrlNormalizer.normalize("wss://relay.damus.io")
val relayUrlOrNull = RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io")
// Handles common fixes: https:// → wss://, strips whitespace, etc.
```
### Build a Filter
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
// Fetch a user's notes
val filter = Filter(
authors = listOf(pubKeyHex),
kinds = listOf(1),
limit = 50
)
// Since a timestamp
val filter = Filter(
kinds = listOf(1, 6),
since = System.currentTimeMillis() / 1000 - 3600 // last hour
)
// By event tags
val filter = Filter(
kinds = listOf(7),
tags = mapOf("e" to listOf(eventId)) // reactions to an event
)
// AND tag filter (NIP-91)
val filter = Filter(
kinds = listOf(1),
tagsAll = mapOf(
"t" to listOf("nostr"),
"p" to listOf(specificPubKey)
)
)
// Full-text search (NIP-50)
val filter = Filter(
kinds = listOf(1),
search = "bitcoin lightning"
)
```
### Open a subscription
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
val relay = RelayUrlNormalizer.normalize("wss://relay.damus.io")
val subId = "my-sub-${System.currentTimeMillis()}"
val filtersMap = mapOf(relay to listOf(filter))
nostrClient.openReqSubscription(
subId = subId,
filters = filtersMap,
listener = object : IRequestListener {
override fun onEvent(subId: String, event: Event, relay: IRelayClient) {
println("Got event: ${event.id}")
}
override fun onEOSE(subId: String, relay: IRelayClient) {
println("End of stored events from ${relay.url}")
}
}
)
// Close when done
nostrClient.close(subId)
```
### Global relay listener
```kotlin
nostrClient.subscribe(object : IRelayClientListener {
override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) {
when (msg) {
is EventMessage -> handleEvent(msg.subscriptionId, msg.event)
is EoseMessage -> handleEose(msg.subscriptionId)
else -> {}
}
}
override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) {
println("Connected to ${relay.url} in ${pingMillis}ms")
}
override fun onDisconnected(relay: IRelayClient) {
println("Disconnected from ${relay.url}")
}
// other callbacks: onConnecting, onSent, onCannotConnect
})
```
---
## 8. Publishing Events
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
val relaySet = setOf(
RelayUrlNormalizer.normalize("wss://relay.damus.io"),
RelayUrlNormalizer.normalize("wss://nos.lol"),
)
// Sign the event
val template = TextNoteEvent.build("Hello Nostr!")
val event: TextNoteEvent = signer.sign(template)
// Send to relays (handles retry + reconnect automatically)
nostrClient.send(event, relaySet)
```
---
## 9. Event Serialization
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.Event
// Serialize to JSON string
val json: String = event.toJson()
// Parse from JSON string
val event: Event = Event.fromJson(json)
// Null-safe parse
val event: Event? = Event.fromJsonOrNull(json)
// Specific typed parse (returns base Event, cast if needed)
val textNote = Event.fromJson(json) as? TextNoteEvent
```
---
## 10. Bech32 Encoding / Decoding (NIP-19)
```kotlin
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
// Decode any bech32 entity
val result = Nip19Parser.uriToRoute("npub1abc...")
// Returns: NPub | NSec | Note | NEvent | NProfile | NAddr | null
when (val r = Nip19Parser.uriToRoute(input)) {
is Nip19Parser.Return.NPub -> println("pubkey: ${r.hex}")
is Nip19Parser.Return.Note -> println("event id: ${r.hex}")
is Nip19Parser.Return.NEvent -> println("event: ${r.hex}, relays: ${r.relays}")
is Nip19Parser.Return.NProfile -> println("profile: ${r.hex}")
is Nip19Parser.Return.NAddr -> println("address: ${r.kind}:${r.pubKey}:${r.dTag}")
null -> println("not a valid bech32 entity")
else -> {}
}
// The parser also handles nostr: URI scheme
val result = Nip19Parser.uriToRoute("nostr:npub1abc...")
```
---
## 11. Encryption
### NIP-44 (modern, recommended)
```kotlin
// Via signer (preferred)
val encrypted = signer.nip44Encrypt(
plaintext = "Secret message",
toPublicKey = recipientPubKeyHex
)
val decrypted = signer.nip44Decrypt(
ciphertext = encrypted,
fromPublicKey = senderPubKeyHex
)
// Auto-detect format (NIP-04 or NIP-44)
val plaintext = signer.decrypt(encryptedContent, fromPublicKeyHex)
```
### NIP-04 (legacy, avoid for new code)
```kotlin
val encrypted = signer.nip04Encrypt(plaintext, recipientPubKeyHex)
val decrypted = signer.nip04Decrypt(ciphertext, senderPubKeyHex)
```
---
## 12. Common NIP Event Builders
### NIP-02 — Follow list (kind 3)
```kotlin
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
val template = ContactListEvent.build(
follows = listOf(
ContactListEvent.Contact(pubKey = alicePubKey, relayUrl = "wss://relay.damus.io", petname = "alice"),
ContactListEvent.Contact(pubKey = bobPubKey)
)
)
```
### NIP-25 — Reaction (kind 7)
```kotlin
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
val like = ReactionEvent.build("+", targetEvent)
val dislike = ReactionEvent.build("-", targetEvent)
val custom = ReactionEvent.build("🤙", targetEvent)
```
### NIP-57 — Zap request (kind 9734)
```kotlin
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
val template = LnZapRequestEvent.build(
message = "Great post!",
relays = listOf("wss://relay.damus.io"),
target = targetEvent,
zapType = LnZapRequestEvent.ZapType.PUBLIC
)
val zapRequest: LnZapRequestEvent = signer.sign(template)
```
### NIP-59 — Gift wrap / sealed DM (kind 1059 + 14)
```kotlin
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
// Creates sealed rumor + gift wrap pair
val (dmEvent, giftWrap) = NIP17Factory.create(
msg = "Private message",
fromSigner = senderSigner,
toUsers = listOf(recipientPubKey),
relayList = listOf("wss://relay.damus.io")
)
```
### NIP-23 — Long-form article (kind 30023)
```kotlin
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
val template = LongTextNoteEvent.build(
body = markdownContent,
title = "My Article",
image = "https://example.com/cover.jpg",
summary = "A brief summary",
slug = "my-article" // d-tag
)
```
---
## 13. Platform-Specific Notes
### JVM / Desktop
```kotlin
// jvmMain dependencies needed in consuming project:
// secp256k1-kmp-jni-jvm and lazysodium-java are transitive from quartz
// But you need JNA on the classpath for libsodium:
implementation("net.java.dev.jna:jna:5.18.1")
```
### Android
```kotlin
// androidMain dependencies (transitive from quartz):
// secp256k1-kmp-jni-android, lazysodium-android, jna (aar)
// No extra setup needed beyond the maven dependency.
// For NIP-55 (Android external signer apps):
import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher
```
### iOS
The library produces an XCFramework named `quartz-kmpKit`.
```bash
# Build XCFramework
./gradlew :quartz:assembleQuartz-kmpKitReleaseXCFramework
# Output: quartz/build/XCFrameworks/release/quartz-kmpKit.xcframework
```
In Xcode: drag & drop the `.xcframework` into your project, then use from Swift via Kotlin/Native interop.
---
## 14. Event Store (Android only)
SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP-62):
```kotlin
import com.vitorpamplona.quartz.nip01Core.store.EventStore
import android.content.Context
val store = EventStore(context)
// Insert
store.insert(event)
// Query
val events = store.query(
Filter(authors = listOf(pubKey), kinds = listOf(1), limit = 50)
)
// Count (NIP-45)
val count = store.count(Filter(kinds = listOf(1)))
// Full-text search (NIP-50)
val results = store.query(Filter(search = "bitcoin"))
```
---
## 15. Quick Reference
| Task | API | Package |
|------|-----|---------|
| Generate keys | `KeyPair()` | `nip01Core.crypto` |
| Create signer | `NostrSignerInternal(keyPair)` | `nip01Core.signers` |
| Build event | `TextNoteEvent.build(...)` or `Event.build(kind, content) { tags }` | `nip10Notes`, `nip01Core.core` |
| Sign event | `signer.sign(template)` | `nip01Core.signers` |
| Serialize | `event.toJson()` | `nip01Core.core` |
| Parse | `Event.fromJson(json)` | `nip01Core.core` |
| Normalize relay URL | `RelayUrlNormalizer.normalize("wss://...")` | `nip01Core.relay.normalizer` |
| Setup relay client | `NostrClient(BasicOkHttpWebSocket.Builder { okhttp })` | `nip01Core.relay.client` |
| Subscribe | `client.openReqSubscription(subId, mapOf(relay to filters), listener)` | `nip01Core.relay.client` |
| Publish | `client.send(event, setOf(relayUrl))` | `nip01Core.relay.client` |
| NIP-44 encrypt | `signer.nip44Encrypt(text, recipientPubKey)` | `nip01Core.signers` |
| Bech32 decode | `Nip19Parser.uriToRoute("npub1...")` | `nip19Bech32` |
| Bech32 encode | `Nip19Bech32.createNPub(pubKeyHex)` | `nip19Bech32` |
## Common Event Kinds
| Kind | Event Type | NIP | Quartz class |
|------|-----------|-----|-------------|
| 0 | User metadata | 01 | `MetadataEvent` |
| 1 | Text note | 10 | `TextNoteEvent` |
| 3 | Follow list | 02 | `ContactListEvent` |
| 4 | Legacy DM | 04 | `PrivateDmEvent` |
| 5 | Deletion | 09 | `DeletionEvent` |
| 6 | Repost | 18 | `RepostEvent` |
| 7 | Reaction | 25 | `ReactionEvent` |
| 14 | Chat message (sealed) | 17 | `NIP17GroupMessage` |
| 1059 | Gift wrap | 59 | `GiftWrapEvent` |
| 9734 | Zap request | 57 | `LnZapRequestEvent` |
| 9735 | Zap receipt | 57 | `LnZapEvent` |
| 10002 | Relay list | 65 | `AdvertisedRelayListEvent` |
| 30023 | Long-form content | 23 | `LongTextNoteEvent` |
## Related Skills
- **nostr-expert** — Internal Quartz patterns for Amethyst development
- **kotlin-multiplatform** — KMP source sets, expect/actual patterns
- **kotlin-coroutines** — Flow patterns for relay event streams
@@ -0,0 +1,143 @@
# Quartz Gradle Dependency Setup
## Current version
```
com.vitorpamplona.quartz:quartz:1.05.1
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
---
## KMP Project Setup
### `gradle/libs.versions.toml`
```toml
[versions]
quartz = "1.05.1"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
```
### `build.gradle.kts` (library/app module)
```kotlin
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary) // or androidLibrary/androidApplication
}
kotlin {
jvm()
androidLibrary {
namespace = "com.example.myapp"
compileSdk = 35
minSdk = 21
}
// optional: iOS targets
sourceSets {
commonMain.dependencies {
implementation(libs.quartz)
}
// No platform-specific deps needed — Quartz provides them transitively
}
}
```
---
## Android-only Project
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.05.1")
}
```
---
## JVM (Desktop) standalone
```kotlin
// build.gradle.kts
plugins {
kotlin("jvm")
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.05.1")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
```
---
## Transitive Dependencies (what you get automatically)
### All platforms (`commonMain`)
- `org.jetbrains.kotlin:kotlin-stdlib`
- `org.jetbrains.kotlinx:kotlinx-coroutines-core`
- `org.jetbrains.kotlinx:kotlinx-collections-immutable`
- `org.jetbrains.kotlinx:kotlinx-serialization-json`
- `androidx.collection:collection` (LruCache)
- `androidx.compose.runtime:runtime-annotation` (@Immutable/@Stable)
- `fr.acinq.secp256k1:secp256k1-kmp` (Schnorr crypto — common)
### JVM + Android (`jvmAndroid`)
- `com.github.anthonynsimon:rfc3986-normalizer` (URL normalization)
- `com.fasterxml.jackson.module:jackson-module-kotlin` (JSON)
- `com.linkedin.urls:url-detector` (URL extraction)
- `com.squareup.okhttp3:okhttp` (WebSocket)
- `ru.gildor.coroutines:kotlin-coroutines-okhttp`
- `nl.bommber:kchesslib` (NIP-64 chess, version pinned to 1.0.0)
### JVM only
- `fr.acinq.secp256k1:secp256k1-kmp-jni-jvm`
- `com.goterl:lazysodium-java` (NIP-44 encryption)
- `net.java.dev.jna:jna`
### Android only
- `fr.acinq.secp256k1:secp256k1-kmp-jni-android`
- `com.goterl:lazysodium-android`
- `net.java.dev.jna:jna` (aar)
- `androidx.core:core-ktx`
---
## Packaging / ProGuard
Quartz ships consumer ProGuard rules automatically (`publish = true` in the library).
You don't need to add manual keep rules for Quartz classes in your app.
For Android release builds, these classes are preserved:
- All `com.vitorpamplona.quartz.**` event and model classes
- Jackson serialization annotations
---
## Common Build Errors
### `Duplicate class kotlin.collections.jdk8`
Add to `gradle.properties`:
```properties
android.useFullClasspathForDexingTransform=true
```
### `Could not find lazysodium-java` on JVM
Ensure JNA is on the classpath:
```kotlin
implementation("net.java.dev.jna:jna:5.18.1")
```
### iOS: `Framework not found quartz-kmpKit`
Build the XCFramework first:
```bash
./gradlew :quartz:assembleQuartz-kmpKitReleaseXCFramework
```
Then drag `quartz/build/XCFrameworks/release/quartz-kmpKit.xcframework` into Xcode.
+18 -160
View File
@@ -1,165 +1,23 @@
# Quartz KMP Conversion Skill
# Quartz KMP (Legacy Skill — Migration Complete)
When working with Quartz library conversion to Kotlin Multiplatform:
> The KMP migration of Quartz is **complete**. This file is kept for historical reference.
>
> For integrating Quartz into external projects, use the **`quartz-integration`** skill instead.
> For working with Quartz internals within Amethyst, use the **`nostr-expert`** skill.
## What was migrated
The Quartz library was successfully converted from Android-only to full KMP supporting:
- **commonMain** — All Nostr protocol logic, events, filters, tags
- **jvmAndroid** — OkHttp WebSocket, Jackson JSON, relay serializers
- **androidMain** — SQLite event store, NIP-55 Android signer
- **jvmMain** — Desktop JVM crypto (lazysodium-java, secp256k1-jni-jvm)
- **iosMain** — iOS targets (XCFramework `quartz-kmpKit`)
## Current artifact
## Current Structure (Android-only)
```
quartz/
├── build.gradle
└── src/
├── main/kotlin/ # All Nostr code here
├── test/
└── androidTest/
com.vitorpamplona.quartz:quartz:1.05.1
```
## Target Structure (KMP)
```
quartz/
├── build.gradle.kts
└── src/
├── commonMain/kotlin/ # Shared protocol code
├── commonTest/kotlin/ # Shared tests
├── androidMain/kotlin/ # Android crypto, storage
├── androidTest/kotlin/
├── jvmMain/kotlin/ # Desktop crypto, storage
└── jvmTest/kotlin/
```
## Platform Abstractions Required
### 1. Cryptography (expect/actual)
```kotlin
// commonMain
expect object Secp256k1 {
fun sign(data: ByteArray, privateKey: ByteArray): ByteArray
fun verify(data: ByteArray, signature: ByteArray, pubKey: ByteArray): Boolean
fun pubKeyCreate(privateKey: ByteArray): ByteArray
}
// androidMain - uses secp256k1-kmp-jni-android
actual object Secp256k1 {
actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray {
return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey)
}
// ...
}
// jvmMain - uses secp256k1-kmp-jni-jvm
actual object Secp256k1 {
actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray {
return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey)
}
// ...
}
```
### 2. NIP-44 Encryption (Sodium)
```kotlin
// commonMain
expect object Nip44 {
fun encrypt(plaintext: String, sharedSecret: ByteArray): String
fun decrypt(ciphertext: String, sharedSecret: ByteArray): String
}
// androidMain - lazysodium-android
// jvmMain - lazysodium-java or libsodium-jni
```
### 3. Secure Random
```kotlin
// commonMain
expect fun secureRandomBytes(size: Int): ByteArray
// androidMain
actual fun secureRandomBytes(size: Int): ByteArray {
return SecureRandom().let { random ->
ByteArray(size).also { random.nextBytes(it) }
}
}
// jvmMain
actual fun secureRandomBytes(size: Int): ByteArray {
return java.security.SecureRandom().let { random ->
ByteArray(size).also { random.nextBytes(it) }
}
}
```
## Build Configuration
```kotlin
// quartz/build.gradle.kts
plugins {
kotlin("multiplatform")
id("com.android.library")
}
kotlin {
androidTarget {
compilations.all {
kotlinOptions.jvmTarget = "17"
}
}
jvm("desktop") {
compilations.all {
kotlinOptions.jvmTarget = "17"
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.collections.immutable)
}
}
val androidMain by getting {
dependencies {
implementation(libs.secp256k1.kmp.jni.android)
implementation(libs.lazysodium.android)
}
}
val desktopMain by getting {
dependencies {
implementation(libs.secp256k1.kmp.jni.jvm)
// lazysodium-java or alternative
}
}
}
}
android {
namespace = "com.vitorpamplona.quartz"
compileSdk = 35
defaultConfig.minSdk = 26
}
```
## Migration Steps
1. **Convert build.gradle to build.gradle.kts** with KMP plugin
2. **Move pure Kotlin code** to `commonMain/`
3. **Identify platform dependencies** (crypto, JNA, Android APIs)
4. **Create expect declarations** for platform-specific APIs
5. **Implement actuals** in androidMain and jvmMain
6. **Update imports** in amethyst module
7. **Test on both platforms**
## Files to Move to commonMain
Most of Quartz can be shared:
- Event classes and parsing
- Filter definitions
- Relay message types
- NIP implementations (logic only)
- Utilities (hex encoding, bech32)
## Files Needing expect/actual
- `Secp256k1.kt` - Signature operations
- `Nip04.kt` - Legacy encryption (uses AES)
- `Nip44.kt` - Modern encryption (uses ChaCha)
- `KeyPair.kt` - Key generation
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
+3
View File
@@ -22,6 +22,9 @@
/.idea/AndroidProjectSystem.xml
/.idea/deviceManager.xml
/.idea/inspectionProfiles/
/commons/.idea/gradle.xml
/commons/.idea/misc.xml
/commons/.idea/workspace.xml
.DS_Store
/build
/captures
-4
View File
@@ -6,10 +6,6 @@
<component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="21" />
</component>
<component name="KotlinCommonCompilerArguments">
<option name="apiVersion" value="2.3" />
<option name="languageVersion" value="2.3" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.3.10" />
</component>
+43 -24
View File
@@ -42,13 +42,13 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Events / Relay Subscriptions (NIP-01)
- [x] Follow List (NIP-02)
- [x] OpenTimestamps Attestations (NIP-03)
- [x] Private Messages (NIP-04)
- [x] Private Messages (NIP-04 -- to be removed)
- [x] DNS Address (NIP-05)
- [x] Mnemonic seed phrase (NIP-06)
- [ ] WebBrowser Signer (NIP-07, Not applicable)
- [x] Old-style mentions (NIP-08)
- [x] Event Deletion (NIP-09)
- [x] Replies, mentions, Threads, and Notifications (NIP-10)
- [x] Text Notes and Threads (NIP-10)
- [x] Relay Information Document (NIP-11)
- [x] Generic Tag Queries (NIP-12)
- [x] Proof of Work Display (NIP-13)
@@ -57,9 +57,9 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [ ] Marketplace (NIP-15)
- [x] Event Treatment (NIP-16)
- [x] Private Direct Messages (NIP-17)
- [x] Image/Video/Url/LnInvoice Previews
- [x] Image/Video/Url/LnInvoice/Cashu Previews
- [x] Reposts, Quotes, Generic Reposts (NIP-18)
- [x] Bech Encoding support (NIP-19)
- [x] Bech32 Encoding support (NIP-19)
- [x] Command Results (NIP-20)
- [x] URI Support (NIP-21)
- [x] Long-form Content (NIP-23) (view only)
@@ -70,45 +70,59 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Public Chats (NIP-28)
- [ ] Relay-based Groups (NIP-29)
- [x] Custom Emoji (NIP-30)
- [x] Event kind summaries (NIP-31)
- [x] Event alt descriptors (NIP-31)
- [ ] Labeling (NIP-32)
- [x] Parameterized Replaceable Events (NIP-33)
- [x] Git Stuff (NIP-34)
- [ ] Torrents (NIP-35)
- [x] Torrents (NIP-35)
- [x] Sensitive Content (NIP-36)
- [x] Edits (NIP-37/Draft)
- [x] Drafts (NIP-37)
- [x] User Status Event (NIP-38)
- [x] External Identities (NIP-39)
- [x] Expiration Support (NIP-40)
- [x] Relay Authentication (NIP-42)
- [ ] Relay Access Metadata and Requests (NIP-43)
- [x] Versioned Encrypted Payloads (NIP-44)
- [ ] Event Counts (NIP-45, Will not implement)
- [ ] Nostr Connect (NIP-46)
- [x] Wallet Connect API (NIP-47)
- [x] Event Counts (NIP-45)
- [o] Nostr Connect (NIP-46)
- [o] Wallet Connect API (NIP-47)
- [ ] Proxy Tags (NIP-48, Not applicable)
- [x] Private key encryption for import/export (NIP-49)
- [x] Online Relay Search (NIP-50)
- [x] Encryption for import/export (NIP-49)
- [x] Relay Search (NIP-50)
- [x] Lists (NIP-51)
- [ ] Calendar Events (NIP-52)
- [o] Calendar Events (NIP-52)
- [x] Live Activities & Live Chats (NIP-53)
- [x] Wiki (NIP-54)
- [x] Inline Metadata (NIP-55 - Draft)
- [x] Android Signer (NIP-55)
- [x] Reporting (NIP-56)
- [x] Lightning Tips
- [x] Zaps (NIP-57)
- [x] Private Zaps
- [x] Zap Splits (NIP-57)
- [x] Private Zaps (NIP-57 / Draft)
- [x] Zapraiser (NIP-57 / Draft)
- [x] Badges (NIP-58)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Zapraiser (NIP-TBD)
- [ ] Relay List Metadata (NIP-65)
- [x] Polls (NIP-69)
- [ ] Cashu Wallets (NIP-60)
- [ ] Nutzaps (NIP-61)
- [x] Request to Vanish (NIP-62)
- [x] Chess (NIP-64)
- [x] Relay List Metadata (NIP-65)
- [ ] Relay Discovery and Monitoring (NIP-66)
- [x] Picture-first feeds (NIP-68)
- [ ] Peer-to-peer Orders (NIP-69)
- [o] Protected Events (NIP-70)
- [x] Video Events (NIP-71)
- [x] Moderated Communities (NIP-72)
- [x] External Content IDs (NIP-73)
- [ ] Zap Goals (NIP-75)
- [ ] Negentropy Syncing (NIP-77)
- [x] Arbitrary Custom App Data (NIP-78)
- [ ] NIP-29 Threads (NIP-7D)
- [x] Highlights (NIP-84)
- [x] Notify Request (NIP-88/Draft)
- [x] Trusted Assertions (NIP-85)
- [ ] Relay Management API (NIP-86)
- [ ] ECash Mint Discoverability (NIP-87)
- [x] Polls (NIP-88)
- [x] Relay Notify Request
- [x] Recommended Application Handlers (NIP-89)
- [x] Data Vending Machine (NIP-90)
- [x] Inline Metadata (NIP-92)
@@ -117,6 +131,14 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] HTTP File Storage Integration (NIP-96)
- [x] HTTP Auth (NIP-98)
- [x] Classifieds (NIP-99)
- [x] Voice Messages (NIP-A0)
- [x] Public Messages (NIP-A4)
- [ ] Web Bookmarks
- [x] Blossom (NIP-B7)
- [ ] Nostr BLE Communications Protocol (NIP-BE)
- [ ] Code Snippets (NIP-C0)
- [ ] NIP-29 Chats (NIP-C7)
- [ ] MLS Protocol (NIP-EE)
- [x] Audio Tracks (zapstr.live) (kind:31337)
- [x] Push Notifications (Google and Unified Push)
- [x] In-Device Automatic Translations
@@ -128,13 +150,10 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Markdown Support
- [x] Medical Data (NIP-xx/Draft)
- [x] Embed events (NIP-xx/Draft)
- [x] Draft Events (NIP-xx/Draft)
- [ ] Event Sets (NIP-xx/Draft)
- [ ] Topical Notes (NIP-xx/Draft)
- [x] Edit Short Notes (NIP-xx/Draft)
- [ ] Relationship Status (NIP-xx/Draft)
- [ ] Signed Filters (NIP-xx/Draft)
- [ ] Key Migration (NIP-xx/Draft)
- [ ] Time-based Sync (NIP-xx/Draft)
- [x] Image Capture in the app
- [x] Video Capture in the app
- [ ] Local Database
+5 -1
View File
@@ -280,7 +280,7 @@ dependencies {
// view videos
implementation libs.androidx.media3.exoplayer
implementation libs.androidx.media3.exoplayer.hls
implementation libs.androidx.media3.ui
implementation libs.androidx.media3.ui.compose.material3
implementation libs.androidx.media3.session
// important for proxy / tor
@@ -294,6 +294,10 @@ dependencies {
implementation libs.coil.svg
// enables network for coil
implementation libs.coil.okhttp
// loads thumbnails for media3
// TODO: Replace this to the FrameExtractor in media 3
// when FrameExtractor accepts custom data sources.
implementation(libs.coil.video)
// Permission to upload pictures:
implementation libs.accompanist.permissions
@@ -121,10 +121,10 @@ class ImageUploadTesting {
context = InstrumentationRegistry.getInstrumentation().targetContext,
)
assertEquals("image/png", result.type)
assertEquals(paylod.size.toLong(), result.size)
assertEquals(initialHash, result.sha256)
assertEquals("${server.baseUrl}/$initialHash", result.url?.removeSuffix(".png"))
assertEquals(server.baseUrl, "image/png", result.type)
assertEquals(server.baseUrl, paylod.size.toLong(), result.size)
assertEquals(server.baseUrl, initialHash, result.sha256)
// assertEquals(server.baseUrl, "${server.baseUrl}/$initialHash", result.url?.removeSuffix(".png"))
val imageData: ByteArray =
ImageDownloader().waitAndGetImage(result.url!!, { client })?.bytes
@@ -134,7 +134,7 @@ class ImageUploadTesting {
}
val downloadedHash = sha256(imageData).toHexKey()
assertEquals(initialHash, downloadedHash)
assertEquals(server.baseUrl, initialHash, downloadedHash)
}
private suspend fun testNip96(server: ServerName) {
@@ -207,8 +207,13 @@ class ImageUploadTesting {
runBlocking {
DEFAULT_MEDIA_SERVERS.forEach {
// skip paid servers and primal server is buggy.
if (!it.name.contains("Paid") && !it.name.contains("Primal")) {
testBase(it)
try {
if (!it.name.contains("Paid") && !it.name.contains("Primal")) {
testBase(it)
}
} catch (e: Exception) {
e.printStackTrace()
fail("Could not upload to: ${it.baseUrl}: ${e.message}")
}
}
}
@@ -226,14 +231,6 @@ class ImageUploadTesting {
testBase(ServerName("nostrage", "https://nostrage.com", ServerType.NIP96))
}
@Test()
@Ignore("Not Working anymore")
fun testSove() =
runBlocking {
testBase(ServerName("sove", "https://sove.rent", ServerType.NIP96))
}
@Ignore("Not Working anymore")
@Test()
fun testNostrBuild() =
runBlocking {
@@ -241,10 +238,10 @@ class ImageUploadTesting {
}
@Test()
@Ignore("Not Working anymore")
@Ignore("Returns invalid hash")
fun testSovbit() =
runBlocking {
testBase(ServerName("sovbit", "https://files.sovbit.host", ServerType.NIP96))
testBase(ServerName("sovbit", "https://cdn.sovbit.host", ServerType.Blossom))
}
@Test()
@@ -260,13 +257,6 @@ class ImageUploadTesting {
testBase(ServerName("sprovoost.nl", "https://img.sprovoost.nl/", ServerType.NIP96))
}
@Test()
@Ignore("Not Working anymore")
fun testNostrOnch() =
runBlocking {
testBase(ServerName("nostr.onch.services", "https://nostr.onch.services", ServerType.NIP96))
}
@Ignore("Changes sha256")
fun testPrimalBlossom() =
runBlocking {
-1
View File
@@ -76,7 +76,6 @@
android:launchMode="singleInstance"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout"
android:taskAffinity=".service.playback.pip.PipVideoActivity"
android:theme="@style/Theme.Amethyst">
<intent-filter android:label="Amethyst">
@@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -73,6 +74,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
@@ -88,7 +90,6 @@ class AppModules(
}
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
val applicationDefaultScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
// Blocking load of UI Preferences to avoid theme/language blinking
val uiPrefs by lazy {
@@ -160,7 +161,7 @@ class AppModules(
TorRelayState(
okHttpClients,
torPrefs.value,
applicationDefaultScope,
applicationIOScope,
)
// Connects the NostrClient class with okHttp
@@ -174,7 +175,7 @@ class AppModules(
val cache: LocalCache = LocalCache
// Provides a relay pool
val client: INostrClient = NostrClient(websocketBuilder, applicationDefaultScope)
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope)
// Watches for changes on Tor and Relay List Settings
val relayProxyClientConnector =
@@ -184,7 +185,7 @@ class AppModules(
connManager,
torManager,
client,
applicationDefaultScope,
applicationIOScope,
)
// Verifies and inserts in the cache from all relays, all subscriptions
@@ -194,10 +195,10 @@ class AppModules(
val notifyCoordinator = NotifyCoordinator(client)
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationDefaultScope)
val authCoordinator = AuthCoordinator(client, applicationIOScope)
// Tries to verify new OTS events when they arrive.
val otsEventVerifier = IncomingOtsEventVerifier(otsVerifCache, cache, applicationDefaultScope)
val otsEventVerifier = IncomingOtsEventVerifier(otsVerifCache, cache, applicationIOScope)
// Tracks if it is possible to connect to relays.
val failureTracker = RelayOfflineTracker(client)
@@ -217,7 +218,7 @@ class AppModules(
client,
authCoordinator.receiver,
failureTracker,
applicationDefaultScope,
applicationIOScope,
)
// keeps all accounts live
@@ -231,12 +232,21 @@ class AppModules(
client = client,
)
val sessionManager =
AccountSessionManager(
accountsCache = accountsCache,
nip05Client = nip05Client,
client = client,
localPreferences = LocalPreferences,
scope = applicationIOScope,
)
// Organizes cache clearing
val trimmingService = MemoryTrimmingService(cache)
// as new accounts are loaded, updates the state of the TorRelaySettings, which produces new TorRelayEvaluator
// and reconnects relays if the configuration has been changed.
val accountsTorStateConnector = AccountsTorStateConnector(accountsCache, torEvaluatorFlow, applicationDefaultScope)
val accountsTorStateConnector = AccountsTorStateConnector(accountsCache, torEvaluatorFlow, applicationIOScope)
// saves the .content of NIP-95 blobs in disk to save memory
val nip95cache: File by lazy { Nip95CacheFactory.new(appContext) }
@@ -274,6 +284,7 @@ class AppModules(
applicationIOScope.launch {
// loads main account quickly.
LocalPreferences.loadAccountConfigFromEncryptedStorage()
sessionManager.loginWithDefaultAccountIfLoggedOff()
}
// forces initialization of uiPrefs in the main thread to avoid blinking themes
@@ -293,17 +304,23 @@ class AppModules(
// registers to receive events
pokeyReceiver.register(appContext)
// initializes diskcache on an IO thread.
applicationIOScope.launch {
// Sets Coil - Tor - OkHttp link
delay(3000)
videoCache
}
}
fun terminate(appContext: Context) {
pokeyReceiver.unregister(appContext)
applicationIOScope.cancel("Application onTerminate $appContext")
applicationDefaultScope.cancel("Application onTerminate $appContext")
accountsCache.clear()
}
fun trim() {
applicationDefaultScope.launch {
applicationIOScope.launch {
val loggedIn = accountsCache.accounts.value.values
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts())
}
@@ -25,9 +25,8 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.vitorpamplona.amethyst.model.ALL_FOLLOWS
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
@@ -54,6 +53,7 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
@@ -102,6 +102,7 @@ private object PrefKeys {
const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList"
const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList"
const val LATEST_INDEX_RELAY_LIST = "latestIndexRelayList"
const val LATEST_FAVORITE_RELAY_LIST = "latestFavoriteRelayList"
const val LATEST_BLOCKED_RELAY_LIST = "latestBlockedRelayList"
const val LATEST_TRUSTED_RELAY_LIST = "latestTrustedRelayList"
const val LATEST_MUTE_LIST = "latestMuteList"
@@ -320,16 +321,11 @@ object LocalPreferences {
PrefKeys.DEFAULT_FILE_SERVER,
JsonMapper.toJson(settings.defaultFileServer),
)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value)
putString(
PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST,
settings.defaultNotificationFollowList.value,
)
putString(
PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST,
settings.defaultDiscoveryFollowList.value,
)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value))
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value))
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNotificationFollowList.value))
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, JsonMapper.toJson(settings.defaultDiscoveryFollowList.value))
putOrRemove(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, settings.zapPaymentRequest.value?.denormalize())
@@ -340,6 +336,7 @@ object LocalPreferences {
putOrRemove(PrefKeys.LATEST_NIP65_RELAY_LIST, settings.backupNIP65RelayList)
putOrRemove(PrefKeys.LATEST_SEARCH_RELAY_LIST, settings.backupSearchRelayList)
putOrRemove(PrefKeys.LATEST_INDEX_RELAY_LIST, settings.backupIndexRelayList)
putOrRemove(PrefKeys.LATEST_FAVORITE_RELAY_LIST, settings.backupIndexRelayList)
putOrRemove(PrefKeys.LATEST_BLOCKED_RELAY_LIST, settings.backupBlockedRelayList)
putOrRemove(PrefKeys.LATEST_TRUSTED_RELAY_LIST, settings.backupTrustedRelayList)
@@ -457,14 +454,10 @@ object LocalPreferences {
getString(PrefKeys.SIGNER_PACKAGE_NAME, null)
?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null
val defaultHomeFollowList =
getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: ALL_FOLLOWS
val defaultStoriesFollowList =
getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultNotificationFollowList =
getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultDiscoveryFollowList =
getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultHomeFollowList = parseOrNull<TopFilter>(PrefKeys.DEFAULT_HOME_FOLLOW_LIST) ?: TopFilter.AllFollows
val defaultStoriesFollowList = parseOrNull<TopFilter>(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST) ?: TopFilter.Global
val defaultNotificationFollowList = parseOrNull<TopFilter>(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST) ?: TopFilter.Global
val defaultDiscoveryFollowList = parseOrNull<TopFilter>(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST) ?: TopFilter.Global
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
val defaultFileServer = parseOrNull<ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0]
@@ -478,6 +471,7 @@ object LocalPreferences {
val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST)
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST)
val latestIndexRelayList = parseEventOrNull<IndexerRelayListEvent>(PrefKeys.LATEST_INDEX_RELAY_LIST)
val latestFavoriteRelayList = parseEventOrNull<FavoriteRelayListEvent>(PrefKeys.LATEST_FAVORITE_RELAY_LIST)
val latestBlockedRelayList = parseEventOrNull<BlockedRelayListEvent>(PrefKeys.LATEST_BLOCKED_RELAY_LIST)
val latestTrustedRelayList = parseEventOrNull<TrustedRelayListEvent>(PrefKeys.LATEST_TRUSTED_RELAY_LIST)
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
@@ -523,6 +517,7 @@ object LocalPreferences {
backupDMRelayList = latestDmRelayList,
backupSearchRelayList = latestSearchRelayList,
backupIndexRelayList = latestIndexRelayList,
backupFavoriteRelayList = latestFavoriteRelayList,
backupBlockedRelayList = latestBlockedRelayList,
backupTrustedRelayList = latestTrustedRelayList,
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
@@ -60,6 +60,8 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays.FavoriteRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays.FavoriteRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
@@ -82,14 +84,12 @@ import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryptionCache
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState
import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListState
import com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineWithIndexRelayListsState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineWithSearchRelayListsState
import com.vitorpamplona.amethyst.model.serverList.MergedServerListState
import com.vitorpamplona.amethyst.model.serverList.TrustedRelayListsState
import com.vitorpamplona.amethyst.model.topNavFeeds.FeedDecryptionCaches
import com.vitorpamplona.amethyst.model.topNavFeeds.FeedTopNavFilterState
@@ -192,6 +192,8 @@ import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
@@ -229,7 +231,7 @@ import kotlin.coroutines.cancellation.CancellationException
@Stable
class Account(
val settings: AccountSettings = AccountSettings(KeyPair()),
val signer: NostrSigner,
override val signer: NostrSigner,
val geolocationFlow: StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: NWCPaymentFilterAssembler,
val otsResolverBuilder: OtsResolverBuilder,
@@ -275,6 +277,9 @@ class Account(
val indexerRelayListDecryptionCache = IndexerRelayListDecryptionCache(signer)
val indexerRelayList = IndexerRelayListState(signer, cache, indexerRelayListDecryptionCache, scope, settings)
val favoriteRelayListDecryptionCache = FavoriteRelayListDecryptionCache(signer)
val favoriteRelayList = FavoriteRelayListState(signer, cache, favoriteRelayListDecryptionCache, scope, settings)
val blockedRelayListDecryptionCache = BlockedRelayListDecryptionCache(signer)
val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings)
@@ -315,8 +320,6 @@ class Account(
val appSpecific = AppSpecificState(signer, cache, scope, settings)
val blossomServers = BlossomServerListState(signer, cache, scope, settings)
val fileStorageServers = FileStorageServerListState(signer, cache, scope, settings)
val serverLists = MergedServerListState(fileStorageServers, blossomServers, scope)
// Relay settings
val homeRelays = AccountHomeRelayState(nip65RelayList, privateStorageRelayList, localRelayList, scope)
@@ -349,7 +352,7 @@ class Account(
override val privateZapsDecryptionCache = PrivateZapCache(signer)
val draftsDecryptionCache = DraftEventCache(signer)
val chatroomList = cache.getOrCreateChatroomList(signer.pubKey)
override val chatroomList = cache.getOrCreateChatroomList(signer.pubKey)
val newNotesPreProcessor = EventProcessor(this, cache)
@@ -527,13 +530,12 @@ class Account(
if (!signer.isWriteable()) return null
if (note.hasReacted(userProfile(), reaction)) return null
val noteEvent = note.event ?: return null
val eventHint = note.toEventHint<Event>() ?: return null
// For NIP-17 private groups, we don't support tracked mode (too complex)
if (noteEvent is NIP17Group) return null
if (eventHint.event is NIP17Group) return null
val relayHint = note.relays.firstOrNull()?.url
val event = ReactionAction.reactTo(noteEvent, reaction, signer, relayHint)
val event = ReactionAction.reactTo(eventHint, reaction, signer)
val relays = computeRelayListToBroadcast(event)
return event to relays
@@ -798,12 +800,7 @@ class Account(
.dmInboxRelayList()
?.relays()
?.ifEmpty { null }
if (relayList != null) {
client.send(event, relayList.toSet())
} else {
val publicRelayList = computeRelayListForLinkedUser(receiver)
client.send(event, publicRelayList)
}
return relayList?.toSet() ?: computeRelayListForLinkedUser(receiver)
} else {
return emptySet()
}
@@ -882,6 +879,10 @@ class Account(
}
}
if (event is PollEvent) {
relayList.addAll(event.relays())
}
relayList.addAll(computeRelaysForChannels(event))
return relayList
@@ -907,12 +908,15 @@ class Account(
note.relays.associateWith { relay ->
listOf(
Filter(
kinds = listOf(host.kind),
tags = mapOf("p" to listOf(pubKey)),
ids = listOf(host.id),
),
)
},
)?.let { downloadedEvent ->
client.send(downloadedEvent, computeRelayListToBroadcast(downloadedEvent))
val toRelays = computeRelayListToBroadcast(downloadedEvent)
client.send(downloadedEvent, toRelays)
}
}
} else {
@@ -991,6 +995,23 @@ class Account(
cache.justConsumeMyOwnEvent(event)
}
suspend fun pollRespond(
event: PollEvent,
responses: Set<String>,
) {
val poll = cache.getOrCreateNote(event.id).toEventHint<PollEvent>()
if (poll != null) {
val template = PollResponseEvent.build(poll, responses)
val signedEvent = signer.sign(template)
cache.justConsumeMyOwnEvent(signedEvent)
client.send(signedEvent, computeRelayListToBroadcast(signedEvent))
}
}
suspend fun createNip95(
byteArray: ByteArray,
headerInfo: FileHeader,
@@ -1337,19 +1358,15 @@ class Account(
}
suspend fun createInteractiveStoryReadingState(
root: InteractiveStoryBaseEvent,
rootRelay: NormalizedRelayUrl?,
readingScene: InteractiveStoryBaseEvent,
readingSceneRelay: NormalizedRelayUrl?,
root: EventHintBundle<InteractiveStoryBaseEvent>,
readingScene: EventHintBundle<InteractiveStoryBaseEvent>,
) {
if (!isWriteable()) return
val template =
InteractiveStoryReadingStateEvent.build(
root = root,
rootRelay = rootRelay,
currentScene = readingScene,
currentSceneRelay = readingSceneRelay,
)
val event = signer.sign(template)
@@ -1368,8 +1385,7 @@ class Account(
suspend fun updateInteractiveStoryReadingState(
readingState: InteractiveStoryReadingStateEvent,
readingScene: InteractiveStoryBaseEvent,
readingSceneRelay: NormalizedRelayUrl?,
readingScene: EventHintBundle<InteractiveStoryBaseEvent>,
) {
if (!isWriteable()) return
@@ -1377,7 +1393,6 @@ class Account(
InteractiveStoryReadingStateEvent.update(
base = readingState,
currentScene = readingScene,
currentSceneRelay = readingSceneRelay,
)
val event = signer.sign(template)
@@ -1555,7 +1570,7 @@ class Account(
broadcast.forEach { client.send(it, relayList) }
}
suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate<PrivateDmEvent>) {
override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate<PrivateDmEvent>) {
if (!isWriteable()) return
val newEvent = signer.sign(eventTemplate)
@@ -1573,11 +1588,18 @@ class Account(
broadcastPrivately(wraps)
}
suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
val events = NIP17Factory().createMessageNIP17(template, signer)
broadcastPrivately(events)
}
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
wraps.forEach { wrap ->
val relayList = computeRelayListToBroadcast(wrap)
client.send(wrap, relayList)
}
}
suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) {
val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) }
@@ -1854,7 +1876,7 @@ class Account(
fun isKnown(user: HexKey): Boolean = user in allFollows.flow.value.authors
fun isAcceptable(note: Note): Boolean {
override fun isAcceptable(note: Note): Boolean {
return note.author?.let { isAcceptable(it) } ?: true &&
// if user hasn't hided this author
isAcceptableDirect(note) &&
@@ -1896,12 +1918,16 @@ class Account(
suspend fun saveTrustedRelayList(trustedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(trustedRelayList.saveRelayList(trustedRelays))
suspend fun saveFavoriteRelayList(trustedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(favoriteRelayList.saveRelayList(trustedRelays))
suspend fun followFavoriteRelay(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(favoriteRelayList.addRelay(url))
suspend fun unfollowFavoriteRelay(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(favoriteRelayList.removeRelay(url))
suspend fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays))
suspend fun sendNip65RelayList(relays: List<AdvertisedRelayInfo>) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays))
suspend fun sendFileServersList(servers: List<String>) = sendMyPublicAndPrivateOutbox(fileStorageServers.saveFileServersList(servers))
suspend fun sendBlossomServersList(servers: List<String>) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers))
fun markAsRead(
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -45,6 +46,7 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
@@ -60,6 +62,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable
import java.util.Locale
val DefaultChannels =
@@ -81,7 +84,7 @@ val DefaultNIP65List =
val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos)
val DefaultSearchRelayList = setOf(Constants.wine, Constants.where, Constants.nostoday, Constants.antiprimal)
val DefaultSearchRelayList = setOf(Constants.wine, Constants.where, Constants.nostoday, Constants.antiprimal, Constants.ditto)
val DefaultIndexerRelayList = setOf(Constants.purplepages, Constants.coracle, Constants.userkinds, Constants.yabu, Constants.nostr1)
@@ -96,20 +99,58 @@ val DefaultSignerPermissions =
Permission(CommandType.DECRYPT_ZAP_EVENT),
)
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val GLOBAL_FOLLOWS = " Global "
@Serializable
sealed class TopFilter(
val code: String,
) {
@Serializable
object Global : TopFilter(" Global ")
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val ALL_FOLLOWS = " All Follows "
@Serializable
object AllFollows : TopFilter(" All Follows ")
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val ALL_USER_FOLLOWS = " All User Follows "
@Serializable
object AllUserFollows : TopFilter(" All User Follows ")
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val KIND3_FOLLOWS = " Main User Follows "
@Serializable
object DefaultFollows : TopFilter(" Main User Follows ")
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val AROUND_ME = " Around Me "
@Serializable
object AroundMe : TopFilter(" Around Me ")
@Serializable
object Chess : TopFilter(" Chess ")
@Serializable
class PeopleList(
val address: Address,
) : TopFilter(address.toValue())
@Serializable
class MuteList(
val address: Address,
) : TopFilter(address.toValue())
@Serializable
class Community(
val address: Address,
) : TopFilter("Community/${address.toValue()}")
@Serializable
class Hashtag(
val tag: String,
) : TopFilter("Hashtag/$tag")
@Serializable
class Geohash(
val tag: String,
) : TopFilter("Geohash/$tag")
@Serializable
class Relay(
val url: String,
) : TopFilter("Relay/$url")
}
@Stable
class AccountSettings(
@@ -118,10 +159,10 @@ class AccountSettings(
var externalSignerPackageName: String? = null,
var localRelayServers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(ALL_FOLLOWS),
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultDiscoveryFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val zapPaymentRequest: MutableStateFlow<Nip47WalletConnect.Nip47URINorm?> = MutableStateFlow(null),
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
@@ -132,6 +173,7 @@ class AccountSettings(
var backupNIP65RelayList: AdvertisedRelayListEvent? = null,
var backupSearchRelayList: SearchRelayListEvent? = null,
var backupIndexRelayList: IndexerRelayListEvent? = null,
var backupFavoriteRelayList: FavoriteRelayListEvent? = null,
var backupBlockedRelayList: BlockedRelayListEvent? = null,
var backupTrustedRelayList: TrustedRelayListEvent? = null,
var backupMuteList: MuteListEvent? = null,
@@ -221,7 +263,7 @@ class AccountSettings(
changeDefaultHomeFollowList(name.code)
}
fun changeDefaultHomeFollowList(name: String) {
fun changeDefaultHomeFollowList(name: TopFilter) {
if (defaultHomeFollowList.value != name) {
defaultHomeFollowList.tryEmit(name)
saveAccountSettings()
@@ -232,7 +274,7 @@ class AccountSettings(
changeDefaultStoriesFollowList(name.code)
}
fun changeDefaultStoriesFollowList(name: String) {
fun changeDefaultStoriesFollowList(name: TopFilter) {
if (defaultStoriesFollowList.value != name) {
defaultStoriesFollowList.tryEmit(name)
saveAccountSettings()
@@ -243,7 +285,7 @@ class AccountSettings(
changeDefaultNotificationFollowList(name.code)
}
fun changeDefaultNotificationFollowList(name: String) {
fun changeDefaultNotificationFollowList(name: TopFilter) {
if (defaultNotificationFollowList.value != name) {
defaultNotificationFollowList.tryEmit(name)
saveAccountSettings()
@@ -254,7 +296,7 @@ class AccountSettings(
changeDefaultDiscoveryFollowList(name.code)
}
fun changeDefaultDiscoveryFollowList(name: String) {
fun changeDefaultDiscoveryFollowList(name: TopFilter) {
if (defaultDiscoveryFollowList.value != name) {
defaultDiscoveryFollowList.tryEmit(name)
saveAccountSettings()
@@ -374,6 +416,16 @@ class AccountSettings(
}
}
fun updateFavoriteRelayList(newFavoriteRelayList: FavoriteRelayListEvent?) {
if (newFavoriteRelayList == null || newFavoriteRelayList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupFavoriteRelayList?.id != newFavoriteRelayList.id) {
backupFavoriteRelayList = newFavoriteRelayList
saveAccountSettings()
}
}
fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) {
if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return
@@ -37,6 +37,7 @@ object Constants {
val nostoday = RelayUrlNormalizer.normalize("wss://search.nos.today")
val antiprimal = RelayUrlNormalizer.normalize("wss://antiprimal.net")
val ditto = RelayUrlNormalizer.normalize("wss://relay.ditto.pub")
val auth = RelayUrlNormalizer.normalize("wss://auth.nostr1.com")
val oxchat = RelayUrlNormalizer.normalize("wss://relay.0xchat.com")
@@ -67,6 +67,18 @@ fun LargeSoftCache<Address, AddressableNote>.filter(
consumer: CacheCollectors.BiFilter<Address, AddressableNote> = ACCEPT_ALL_FILTER,
): List<AddressableNote> = filter(kindStart(kind, pubKey), kindEnd(kind, pubKey), consumer)
fun LargeSoftCache<Address, AddressableNote>.filter(
kinds: List<Int>,
pubKey: HexKey,
consumer: CacheCollectors.BiFilter<Address, AddressableNote> = ACCEPT_ALL_FILTER,
): Set<AddressableNote> {
val set = mutableSetOf<AddressableNote>()
kinds.forEach {
set.addAll(filterIntoSet(kindStart(it, pubKey), kindEnd(it, pubKey), consumer))
}
return set
}
fun LargeSoftCache<Address, AddressableNote>.filterIntoSet(
kind: Int,
consumer: CacheCollectors.BiFilter<Address, AddressableNote> = ACCEPT_ALL_FILTER,
@@ -61,7 +61,6 @@ import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProvide
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
@@ -133,6 +132,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
@@ -148,6 +148,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
@@ -168,6 +169,13 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent
import com.vitorpamplona.quartz.nip64Chess.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
@@ -180,6 +188,8 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent
@@ -193,6 +203,7 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -219,7 +230,9 @@ interface ILocalCache {
fun markAsSeen(
eventId: String,
relay: NormalizedRelayUrl,
) {}
) {
// Default no-op; implementations may override to track seen events per relay
}
}
object LocalCache : ILocalCache, ICacheProvider {
@@ -411,18 +424,18 @@ object LocalCache : ILocalCache, ICacheProvider {
return null
}
override fun checkGetOrCreateNote(key: String): Note? {
if (ATag.isATag(key)) {
return checkGetOrCreateAddressableNote(key)
override fun checkGetOrCreateNote(hexKey: String): Note? {
if (ATag.isATag(hexKey)) {
return checkGetOrCreateAddressableNote(hexKey)
}
if (isValidHex(key)) {
val note = getOrCreateNote(key)
if (isValidHex(hexKey)) {
val note = getOrCreateNote(hexKey)
val noteEvent = note.event
return if (noteEvent is AddressableEvent) {
// upgrade to the latest
val newNote = checkGetOrCreateAddressableNote(noteEvent.aTag().toTag())
val newNote = getOrCreateAddressableNote(noteEvent.address())
if (newNote != null && newNote.event == null) {
if (newNote.event == null) {
val author = note.author ?: getOrCreateUser(noteEvent.pubKey)
newNote.loadEvent(noteEvent as Event, author, emptyList())
note.moveAllReferencesTo(newNote)
@@ -551,6 +564,12 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
fun consume(
event: ExternalIdentitiesEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: ContactListEvent,
relay: NormalizedRelayUrl?,
@@ -715,6 +734,49 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
// Chess events (NIP-64 live chess)
fun consume(
event: ChessGameEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: JesterEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: LiveChessGameChallengeEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: LiveChessGameAcceptEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: LiveChessMoveEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: LiveChessGameEndEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: LiveChessDrawOfferEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: NipTextEvent,
relay: NormalizedRelayUrl?,
@@ -1150,6 +1212,12 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: FavoriteRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: TrustProviderListEvent,
relay: NormalizedRelayUrl?,
@@ -1355,10 +1423,13 @@ object LocalCache : ILocalCache, ICacheProvider {
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consumeBaseReplaceable(
event: BaseAddressableEvent,
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
// TODO: Redo the Event sctructure in Quartz to avoid this check
check(event is AddressableEvent) { "Event must be addressable: ${event.kind}" }
val version = getOrCreateNote(event.id)
val replaceableNote = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
@@ -1520,6 +1591,12 @@ object LocalCache : ILocalCache, ICacheProvider {
deleteNote.author?.statusStateOrNull()?.removeStatus(deleteNote)
}
if (deletedEvent is PollResponseEvent) {
deletedEvent.poll()?.eventId?.let {
getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(deleteNote)
}
}
if (deletedEvent is TorrentCommentEvent) {
deletedEvent.torrentIds()?.let {
getNoteIfExists(it)?.removeReply(deleteNote)
@@ -1998,6 +2075,38 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: CodeSnippetEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: PollEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: PollResponseEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val pollId = event.poll()?.eventId
if (pollId != null) {
val pollNote = getOrCreateNote(pollId)
val responseNote = getOrCreateNote(event.id)
val new = consumeRegularEvent(event, relay, wasVerified)
if (new) {
pollNote.pollState().addResponse(responseNote)
}
return new
}
return false
}
fun consume(
event: FileStorageEvent,
relay: NormalizedRelayUrl?,
@@ -2611,6 +2720,12 @@ object LocalCache : ILocalCache, ICacheProvider {
note.author?.statusStateOrNull()?.removeStatus(note)
}
if (noteEvent is PollResponseEvent) {
noteEvent.poll()?.eventId?.let {
getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(note)
}
}
note.clearFlow()
notes.remove(note.idHex)
@@ -2677,7 +2792,7 @@ object LocalCache : ILocalCache, ICacheProvider {
note?.event?.let { noteEvent ->
if (noteEvent is AddressableEvent) {
getAddressableNoteIfExists(noteEvent.aTag().toTag())?.addRelay(relay)
getAddressableNoteIfExists(noteEvent.address())?.addRelay(relay)
}
}
@@ -2951,6 +3066,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is EmojiPackSelectionEvent -> consume(event, relay, wasVerified)
is EphemeralChatEvent -> consume(event, relay, wasVerified)
is EphemeralChatListEvent -> consume(event, relay, wasVerified)
is ExternalIdentitiesEvent -> consume(event, relay, wasVerified)
is GenericRepostEvent -> consume(event, relay, wasVerified)
is FhirResourceEvent -> consume(event, relay, wasVerified)
is FileHeaderEvent -> consume(event, relay, wasVerified)
@@ -2966,6 +3082,14 @@ object LocalCache : ILocalCache, ICacheProvider {
is GitReplyEvent -> consume(event, relay, wasVerified)
is GitPatchEvent -> consume(event, relay, wasVerified)
is GitRepositoryEvent -> consume(event, relay, wasVerified)
is ChessGameEvent -> consume(event, relay, wasVerified)
is FavoriteRelayListEvent -> consume(event, relay, wasVerified)
is JesterEvent -> consume(event, relay, wasVerified)
is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified)
is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified)
is LiveChessMoveEvent -> consume(event, relay, wasVerified)
is LiveChessGameEndEvent -> consume(event, relay, wasVerified)
is LiveChessDrawOfferEvent -> consume(event, relay, wasVerified)
is HashtagListEvent -> consume(event, relay, wasVerified)
is HighlightEvent -> consume(event, relay, wasVerified)
is IndexerRelayListEvent -> consume(event, relay, wasVerified)
@@ -2997,7 +3121,10 @@ object LocalCache : ILocalCache, ICacheProvider {
is PinListEvent -> consume(event, relay, wasVerified)
is PublicMessageEvent -> consume(event, relay, wasVerified)
is PeopleListEvent -> consume(event, relay, wasVerified)
is CodeSnippetEvent -> consume(event, relay, wasVerified)
is PollNoteEvent -> consume(event, relay, wasVerified)
is PollEvent -> consume(event, relay, wasVerified)
is PollResponseEvent -> consume(event, relay, wasVerified)
is ReactionEvent -> consume(event, relay, wasVerified)
is ContactCardEvent -> consume(event, relay, wasVerified)
is RelaySetEvent -> consume(event, relay, wasVerified)
@@ -20,10 +20,12 @@
*/
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
@Stable
class UiSettingsFlow(
val theme: MutableStateFlow<ThemeType> = MutableStateFlow(ThemeType.SYSTEM),
val preferredLanguage: MutableStateFlow<String?> = MutableStateFlow(null),
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -51,6 +52,12 @@ class UserMetadataState(
fun getUserMetadataEvent(): MetadataEvent? = note.event as? MetadataEvent
fun getIdentitiesEventAddress() = ExternalIdentitiesEvent.createAddress(signer.pubKey)
val identitiesNote = cache.getOrCreateAddressableNote(getIdentitiesEventAddress())
fun getExternalIdentitiesEvent(): ExternalIdentitiesEvent? = identitiesNote.event as? ExternalIdentitiesEvent
suspend fun sendNewUserMetadata(
name: String? = null,
displayName: String? = null,
@@ -62,9 +69,6 @@ class UserMetadataState(
nip05: String? = null,
lnAddress: String? = null,
lnURL: String? = null,
twitter: String? = null,
mastodon: String? = null,
github: String? = null,
): MetadataEvent {
val latest = getUserMetadataEvent()
@@ -82,9 +86,6 @@ class UserMetadataState(
nip05 = nip05,
lnAddress = lnAddress,
lnURL = lnURL,
twitter = twitter,
mastodon = mastodon,
github = github,
)
} else {
MetadataEvent.createNew(
@@ -98,6 +99,29 @@ class UserMetadataState(
nip05 = nip05,
lnAddress = lnAddress,
lnURL = lnURL,
)
}
return signer.sign(template)
}
suspend fun sendNewUserIdentities(
twitter: String? = null,
mastodon: String? = null,
github: String? = null,
): ExternalIdentitiesEvent {
val latest = getExternalIdentitiesEvent()
val template =
if (latest != null) {
ExternalIdentitiesEvent.updateFromPast(
latest = latest,
twitter = twitter,
mastodon = mastodon,
github = github,
)
} else {
ExternalIdentitiesEvent.createNew(
twitter = twitter,
mastodon = mastodon,
github = github,
@@ -21,12 +21,14 @@
package com.vitorpamplona.amethyst.model.nip11RelayInfo
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import okhttp3.OkHttpClient
@Stable
class Nip11CachedRetriever(
val okHttpClient: (NormalizedRelayUrl) -> OkHttpClient,
) {
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent
class FavoriteRelayListDecryptionCache(
signer: NostrSigner,
) : GenericRelayListCache<FavoriteRelayListEvent>(signer)
@@ -0,0 +1,134 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class FavoriteRelayListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: FavoriteRelayListDecryptionCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val favoriteListNote = cache.getOrCreateAddressableNote(getFavoriteRelayListAddress())
fun getFavoriteRelayListAddress() = FavoriteRelayListEvent.createAddress(signer.pubKey)
fun getFavoriteRelayListFlow(): StateFlow<NoteState> = favoriteListNote.flow().metadata.stateFlow
fun getFavoriteRelayList(): FavoriteRelayListEvent? = favoriteListNote.event as? FavoriteRelayListEvent
fun favoriteListEvent(note: Note) = note.event as? FavoriteRelayListEvent ?: settings.backupFavoriteRelayList
suspend fun normalizeFavoriteRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = favoriteListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: emptySet()
suspend fun normalizeFavoriteRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = favoriteListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
val flow =
getFavoriteRelayListFlow()
.map { normalizeFavoriteRelayListWithBackup(it.note) }
.onStart { emit(normalizeFavoriteRelayListWithBackup(favoriteListNote)) }
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
val flowNoDefaults =
getFavoriteRelayListFlow()
.map { normalizeFavoriteRelayListWithBackupNoDefaults(it.note) }
.onStart { emit(normalizeFavoriteRelayListWithBackupNoDefaults(favoriteListNote)) }
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
suspend fun addRelay(relay: NormalizedRelayUrl): FavoriteRelayListEvent {
val current = normalizeFavoriteRelayListWithBackupNoDefaults(favoriteListNote).toMutableList()
if (relay !in current) current.add(relay)
return saveRelayList(current)
}
suspend fun removeRelay(relay: NormalizedRelayUrl): FavoriteRelayListEvent? {
val current = normalizeFavoriteRelayListWithBackupNoDefaults(favoriteListNote).toMutableList()
if (relay !in current) return null
current.remove(relay)
return saveRelayList(current)
}
suspend fun saveRelayList(favoriteRelays: List<NormalizedRelayUrl>): FavoriteRelayListEvent {
val relayListForFavorite = getFavoriteRelayList()
return if (relayListForFavorite != null && relayListForFavorite.tags.isNotEmpty()) {
FavoriteRelayListEvent.updateRelayList(
earlierVersion = relayListForFavorite,
relays = favoriteRelays,
signer = signer,
)
} else {
FavoriteRelayListEvent.create(
relays = favoriteRelays,
signer = signer,
)
}
}
init {
settings.backupFavoriteRelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved favorite relay list ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
}
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Favorite Relay List Collector Start")
getFavoriteRelayListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Favorite Relay List for ${signer.pubKey}")
(it.note.event as? FavoriteRelayListEvent)?.let {
settings.updateFavoriteRelayList(it)
}
}
}
}
}
@@ -0,0 +1,146 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip64Chess
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.GameResult
import com.vitorpamplona.quartz.nip64Chess.GameTermination
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
/**
* Action class for creating and signing live chess events
*/
class ChessAction {
companion object {
/**
* Create a new chess game challenge
*
* @param gameId Unique identifier for the game
* @param playerColor Color the challenger wants to play
* @param opponentPubkey Optional opponent pubkey (null = open challenge)
* @param timeControl Optional time control (e.g., "10+0")
* @param signer Nostr signer
*/
suspend fun createChallenge(
gameId: String,
playerColor: Color,
opponentPubkey: String? = null,
timeControl: String? = null,
signer: NostrSigner,
): LiveChessGameChallengeEvent =
signer.sign(
LiveChessGameChallengeEvent.build(
gameId = gameId,
playerColor = playerColor,
opponentPubkey = opponentPubkey,
timeControl = timeControl,
),
)
/**
* Accept a chess game challenge
*
* @param gameId Game identifier from the challenge
* @param challengeEventId Event ID of the challenge
* @param challengerPubkey Pubkey of the challenger
* @param signer Nostr signer
*/
suspend fun acceptChallenge(
gameId: String,
challengeEventId: String,
challengerPubkey: String,
signer: NostrSigner,
): LiveChessGameAcceptEvent =
signer.sign(
LiveChessGameAcceptEvent.build(
gameId = gameId,
challengeEventId = challengeEventId,
challengerPubkey = challengerPubkey,
),
)
/**
* Publish a move in a live chess game
*
* @param gameId Game identifier
* @param moveNumber Move number (1-based)
* @param san Move in Standard Algebraic Notation
* @param fen Resulting position in FEN notation
* @param opponentPubkey Opponent's pubkey
* @param comment Optional move comment
* @param signer Nostr signer
*/
suspend fun publishMove(
gameId: String,
moveNumber: Int,
san: String,
fen: String,
opponentPubkey: String,
comment: String = "",
signer: NostrSigner,
): LiveChessMoveEvent =
signer.sign(
LiveChessMoveEvent.build(
gameId = gameId,
moveNumber = moveNumber,
san = san,
fen = fen,
opponentPubkey = opponentPubkey,
comment = comment,
),
)
/**
* End a chess game and publish result
*
* @param gameId Game identifier
* @param result Game result (1-0, 0-1, or 1/2-1/2)
* @param termination How the game ended
* @param winnerPubkey Pubkey of winner (if applicable)
* @param opponentPubkey Opponent's pubkey
* @param pgn Optional PGN of complete game
* @param signer Nostr signer
*/
suspend fun endGame(
gameId: String,
result: GameResult,
termination: GameTermination,
winnerPubkey: String? = null,
opponentPubkey: String,
pgn: String = "",
signer: NostrSigner,
): LiveChessGameEndEvent =
signer.sign(
LiveChessGameEndEvent.build(
gameId = gameId,
result = result,
termination = termination,
winnerPubkey = winnerPubkey,
opponentPubkey = opponentPubkey,
pgn = pgn,
),
)
}
}
@@ -1,81 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip96FileStorage
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
class FileStorageServerListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val fileStorageListNote = cache.getOrCreateAddressableNote(getFileServersAddress())
fun getFileServersAddress() = FileServersEvent.createAddress(signer.pubKey)
fun getFileServersListFlow(): StateFlow<NoteState> = fileStorageListNote.flow().metadata.stateFlow
fun getFileServersList(): FileServersEvent? = fileStorageListNote.event as? FileServersEvent
fun normalizeServers(note: Note): List<String> {
val event = note.event as? FileServersEvent
return event?.servers() ?: emptyList()
}
val flow =
getFileServersListFlow()
.map { normalizeServers(it.note) }
.onStart { emit(normalizeServers(fileStorageListNote)) }
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
suspend fun saveFileServersList(servers: List<String>): FileServersEvent {
val serverList = getFileServersList()
val template =
if (serverList != null && serverList.tags.isNotEmpty()) {
FileServersEvent.replaceServers(serverList, servers)
} else {
FileServersEvent.build(servers)
}
return signer.sign(template)
}
}
@@ -24,10 +24,14 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.utils.Rfc3986
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
@@ -57,6 +61,13 @@ class BlossomServerListState(
return event?.servers() ?: emptyList()
}
fun host(url: String): String =
try {
Rfc3986.host(url).removePrefix("cdn.").removePrefix("blossom.")
} catch (e: Exception) {
url.removePrefix("cdn.").removePrefix("blossom.")
}
val flow =
getBlossomServersListFlow()
.map { normalizeServers(it.note) }
@@ -68,6 +79,21 @@ class BlossomServerListState(
emptyList(),
)
fun mergeServerList(blossom: List<String>?): List<ServerName> = blossom?.map { ServerName(host(it), it, ServerType.Blossom) } ?: emptyList()
val hostNameFlow: StateFlow<List<ServerName>> =
flow
.map { blossoms ->
mergeServerList(blossoms)
}.onStart {
emit(mergeServerList(flow.value))
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
DEFAULT_MEDIA_SERVERS,
)
suspend fun saveBlossomServersList(servers: List<String>): BlossomServersEvent {
val serverList = getBlossomServersList()
@@ -101,7 +101,7 @@ class TorSharedPreferences(
videosViaTor = preferences[VIDEOS_VIA_TOR_KEY] ?: false,
moneyOperationsViaTor = preferences[MONEY_OPERATIONS_VIA_TOR_KEY] ?: false,
nip05VerificationsViaTor = preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] ?: false,
nip96UploadsViaTor = preferences[MEDIA_UPLOADS_VIA_TOR_KEY] ?: false,
mediaUploadsViaTor = preferences[MEDIA_UPLOADS_VIA_TOR_KEY] ?: false,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -125,7 +125,7 @@ class TorSharedPreferences(
preferences[VIDEOS_VIA_TOR_KEY] = torSettings.videosViaTor
preferences[MONEY_OPERATIONS_VIA_TOR_KEY] = torSettings.moneyOperationsViaTor
preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] = torSettings.nip05VerificationsViaTor
preferences[MEDIA_UPLOADS_VIA_TOR_KEY] = torSettings.nip96UploadsViaTor
preferences[MEDIA_UPLOADS_VIA_TOR_KEY] = torSettings.mediaUploadsViaTor
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -122,8 +122,8 @@ class RoleBasedHttpClientBuilder(
fun shouldUseTorForUploads(url: String) =
when (torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.nip96UploadsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.nip96UploadsViaTor.value)
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.mediaUploadsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.mediaUploadsViaTor.value)
}
override fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(shouldUseTorForVideoDownload(url))
@@ -1,73 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.serverList
import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListState
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.quartz.utils.Rfc3986
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
class MergedServerListState(
val fileServers: FileStorageServerListState,
val blossomServers: BlossomServerListState,
val scope: CoroutineScope,
) {
fun host(url: String): String =
try {
Rfc3986.host(url)
} catch (e: Exception) {
url
}
fun mergeServerList(
nip96: List<String>?,
blossom: List<String>?,
): List<ServerName> {
val nip96servers = nip96?.map { ServerName(host(it), it, ServerType.NIP96) } ?: emptyList()
val blossomServers = blossom?.map { ServerName(host(it), it, ServerType.Blossom) } ?: emptyList()
val result = (nip96servers + blossomServers).ifEmpty { DEFAULT_MEDIA_SERVERS }
return result + ServerName("NIP95", "", ServerType.NIP95)
}
val liveServerList: StateFlow<List<ServerName>> =
combine(fileServers.flow, blossomServers.flow) { nip96s, blossoms ->
mergeServerList(nip96s, blossoms)
}.onStart {
emit(mergeServerList(fileServers.flow.value, blossomServers.flow.value))
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
DEFAULT_MEDIA_SERVERS,
)
}
@@ -20,18 +20,15 @@
*/
package com.vitorpamplona.amethyst.model.topNavFeeds
import com.vitorpamplona.amethyst.model.ALL_FOLLOWS
import com.vitorpamplona.amethyst.model.ALL_USER_FOLLOWS
import com.vitorpamplona.amethyst.model.AROUND_ME
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFollowsFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownFeedFlow
@@ -51,7 +48,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
class FeedTopNavFilterState(
val feedFilterListName: MutableStateFlow<String>,
val feedFilterListName: MutableStateFlow<TopFilter>,
val kind3Follows: StateFlow<Kind3FollowListState.Kind3Follows>,
val allFollows: StateFlow<MergedFollowListsState.AllFollows>,
val locationFlow: StateFlow<LocationState.LocationResult>,
@@ -62,35 +59,84 @@ class FeedTopNavFilterState(
val signer: NostrSigner,
val scope: CoroutineScope,
) {
fun loadFlowsFor(listName: String): IFeedFlowsType =
fun loadFlowsFor(listName: TopFilter): IFeedFlowsType =
when (listName) {
GLOBAL_FOLLOWS -> {
TopFilter.Global -> {
GlobalFeedFlow(followsRelays, proxyRelays)
}
ALL_FOLLOWS -> {
TopFilter.AllFollows -> {
AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays)
}
ALL_USER_FOLLOWS -> {
TopFilter.AllUserFollows -> {
AllUserFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays)
}
KIND3_FOLLOWS -> {
TopFilter.DefaultFollows -> {
Kind3UserFollowsFeedFlow(kind3Follows, followsRelays, blockedRelays, proxyRelays)
}
AROUND_ME -> {
TopFilter.AroundMe -> {
AroundMeFeedFlow(locationFlow, followsRelays, proxyRelays)
}
else -> {
val note = LocalCache.checkGetOrCreateAddressableNote(listName)
if (note != null) {
NoteFeedFlow(note.flow().metadata.stateFlow, signer, followsRelays, blockedRelays, proxyRelays, caches)
} else {
UnknownFeedFlow(listName)
}
TopFilter.Chess -> {
ChessFeedFlow(followsRelays, proxyRelays)
}
is TopFilter.Community -> {
NoteFeedFlow(
LocalCache
.getOrCreateAddressableNote(listName.address)
.flow()
.metadata.stateFlow,
signer,
followsRelays,
blockedRelays,
proxyRelays,
caches,
)
}
is TopFilter.PeopleList -> {
NoteFeedFlow(
LocalCache
.getOrCreateAddressableNote(listName.address)
.flow()
.metadata.stateFlow,
signer,
followsRelays,
blockedRelays,
proxyRelays,
caches,
)
}
is TopFilter.MuteList -> {
NoteFeedFlow(
LocalCache
.getOrCreateAddressableNote(listName.address)
.flow()
.metadata.stateFlow,
signer,
followsRelays,
blockedRelays,
proxyRelays,
caches,
)
}
is TopFilter.Geohash -> {
UnknownFeedFlow(listName)
}
is TopFilter.Hashtag -> {
UnknownFeedFlow(listName)
}
is TopFilter.Relay -> {
UnknownFeedFlow(listName)
}
}
@@ -18,26 +18,26 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.okhttp
package com.vitorpamplona.amethyst.model.topNavFeeds.chess
import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation
import com.vitorpamplona.amethyst.model.torState.TorRelaySettings
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class ProxySettingsAnchor {
val flow: MutableStateFlow<StateFlow<TorRelayEvaluation>> =
MutableStateFlow(
MutableStateFlow(
TorRelayEvaluation(
torSettings = TorRelaySettings(),
trustedRelayList = emptySet(),
dmRelayList = emptySet(),
),
),
)
class ChessFeedFlow(
val outboxRelays: StateFlow<Set<NormalizedRelayUrl>>,
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedFlowsType {
val default = ChessTopNavFilter(outboxRelays, proxyRelays)
var isDM: (NormalizedRelayUrl) -> Boolean = { it in flow.value.value.dmRelayList }
var useProxy: (NormalizedRelayUrl) -> Boolean = { flow.value.value.useTor(it) }
override fun flow() = MutableStateFlow(default)
override fun startValue(): ChessTopNavFilter = default
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
collector.emit(startValue())
}
}
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.chess
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@Immutable
class ChessTopNavFilter(
val outboxRelays: StateFlow<Set<NormalizedRelayUrl>>,
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedTopNavFilter {
override fun matchAuthor(pubkey: HexKey): Boolean = true
override fun match(noteEvent: Event) =
noteEvent is ChessGameEvent ||
noteEvent is LiveChessGameChallengeEvent ||
noteEvent is LiveChessGameEndEvent
override fun toPerRelayFlow(cache: LocalCache): Flow<ChessTopNavPerRelayFilterSet> =
combine(outboxRelays, proxyRelays) { outboxRelays, proxyRelays ->
if (proxyRelays.isNotEmpty()) {
ChessTopNavPerRelayFilterSet(proxyRelays.associateWith { ChessTopNavPerRelayFilter })
} else {
ChessTopNavPerRelayFilterSet(outboxRelays.associateWith { ChessTopNavPerRelayFilter })
}
}
override fun startValue(cache: LocalCache): ChessTopNavPerRelayFilterSet =
if (proxyRelays.value.isNotEmpty()) {
ChessTopNavPerRelayFilterSet(proxyRelays.value.associateWith { ChessTopNavPerRelayFilter })
} else {
ChessTopNavPerRelayFilterSet(outboxRelays.value.associateWith { ChessTopNavPerRelayFilter })
}
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.chess
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter
@Immutable
object ChessTopNavPerRelayFilter : IFeedTopNavPerRelayFilter
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.chess
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class ChessTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, ChessTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
@@ -20,13 +20,14 @@
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.unknown
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableStateFlow
class UnknownFeedFlow(
val feedName: String,
val feedName: TopFilter,
) : IFeedFlowsType {
override fun flow() = MutableStateFlow(UnknownTopNavFilter(feedName))
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.unknown
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -30,7 +31,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
@Immutable
class UnknownTopNavFilter(
val feedName: String,
val feedName: TopFilter,
) : IFeedTopNavFilter {
override fun matchAuthor(pubkey: HexKey) = false
@@ -91,6 +91,7 @@ class MeltProcessor {
onError: (String, String) -> Unit,
context: Context,
) {
// TODO: Implement Cashu token melting via Lightning invoice
}
suspend fun feeCalculator(
@@ -40,6 +40,7 @@ import coil3.request.Options
import coil3.size.Precision
import coil3.svg.SvgDecoder
import coil3.util.Logger
import coil3.video.VideoFrameDecoder
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.quartz.utils.Log
import okhttp3.Call
@@ -73,6 +74,7 @@ class ImageLoaderSetup {
.components {
add(gifFactory)
add(svgFactory)
add(VideoFrameDecoder.Factory())
add(Base64Fetcher.Factory)
add(BlurHashFetcher.Factory)
add(Base64Fetcher.BKeyer)
@@ -25,7 +25,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.ui.platform.LocalView
import androidx.media3.common.Player
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
@@ -33,16 +32,16 @@ import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
@Composable
fun ControlWhenPlayerIsActive(
mediaControllerState: MediaControllerState,
automaticallyStartPlayback: State<Boolean>,
automaticallyStartPlayback: Boolean,
isClosestToTheCenterOfTheScreen: MutableState<Boolean>,
) {
val controller = mediaControllerState.controller ?: return
val controller = mediaControllerState.controller
LaunchedEffect(key1 = isClosestToTheCenterOfTheScreen.value, key2 = mediaControllerState) {
// active means being fully visible
if (isClosestToTheCenterOfTheScreen.value) {
// should auto start video from settings?
if (!automaticallyStartPlayback.value) {
if (!automaticallyStartPlayback) {
if (controller.isPlaying) {
// if it is visible, it's playing but it wasn't supposed to start automatically.
controller.pause()
@@ -21,225 +21,55 @@
package com.vitorpamplona.amethyst.service.playback.composable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.Player
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.flow.onEach
@Composable
fun GetVideoController(
mediaItem: LoadedMediaItem,
muted: Boolean = false,
play: Boolean = false,
inner: @Composable (mediaControllerState: MediaControllerState) -> Unit,
) {
val context = LocalContext.current
val controllerState by remember(mediaItem) {
PlaybackServiceClient
.controllerAsFlow(
videoUri = mediaItem.src.videoUri,
proxyPort = mediaItem.src.proxyPort,
keepPlaying = mediaItem.src.keepPlaying,
context = context,
).onEach { state ->
Log.d("PlaybackService", "Controller instance: ${state.controller}")
val onlyOnePreparing = remember { AtomicBoolean() }
val controllerId = remember(mediaItem.src.videoUri) { MediaControllerState() }
controllerId.composed = true
val scope = rememberCoroutineScope()
// Prepares a VideoPlayer from the foreground service.
//
// TODO: Review this code because a new Disposable Effect can run
// before the onDispose of the previous composable and the onDispose
// sometimes affects the new variables, not the old ones.
DisposableEffect(key1 = mediaItem.src.videoUri) {
// If it is not null, the user might have come back from a playing video, like clicking on
// the notification of the video player.
if (controllerId.needsController()) {
// If there is a connection, don't wait.
if (!onlyOnePreparing.getAndSet(true)) {
scope.launch(Dispatchers.IO) {
Log.d("PlaybackService", "Preparing Video ${controllerId.id} ${mediaItem.src.videoUri}")
PlaybackServiceClient.prepareController(
mediaControllerState = controllerId,
videoUri = mediaItem.src.videoUri,
proxyPort = mediaItem.src.proxyPort,
keepPlaying = mediaItem.src.keepPlaying,
context = context,
) { controllerId ->
scope.launch(Dispatchers.Main) {
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
onlyOnePreparing.getAndSet(false)
PlaybackServiceClient.removeController(controllerId)
return@launch
}
// REQUIRED TO BE RUN IN THE MAIN THREAD
if (!controllerId.isPlaying()) {
if (BackgroundMedia.isPlaying()) {
// There is a video playing, start this one on mute.
controllerId.controller?.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
controllerId.controller?.volume = if (muted) 0f else 1f
}
}
controllerId.controller?.setMediaItem(mediaItem.item)
controllerId.controller?.prepare()
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
PlaybackServiceClient.removeController(controllerId)
return@launch
}
controllerId.readyToDisplay.value = true
onlyOnePreparing.getAndSet(false)
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
PlaybackServiceClient.removeController(controllerId)
return@launch
}
}
}
if (BackgroundMedia.isPlaying()) {
// There is a video playing, start this one on mute.
state.controller.volume = 0f
Log.d("PlaybackService", "OnEach Muted due to BackgroundMedia.isPlaying")
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
state.controller.volume = if (muted) 0f else 1f
Log.d("PlaybackService", "OnEach $muted")
}
}
} else {
// has been loaded. prepare to play. This happens when the background video switches screens.
controllerId.controller?.let {
scope.launch {
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
PlaybackServiceClient.removeController(controllerId)
return@launch
}
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
Log.d("PlaybackService", "Preparing Existing Video ${mediaItem.src.videoUri} ")
if (it.isPlaying) {
// There is a video playing, start this one on mute.
it.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
it.volume = if (muted) 0f else 1f
}
if (mediaItem.item != it.currentMediaItem) {
it.setMediaItem(mediaItem.item)
}
it.prepare()
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
PlaybackServiceClient.removeController(controllerId)
return@launch
}
}
if (play) {
state.controller.playWhenReady = true
}
state.controller.setMediaItem(mediaItem.item)
state.controller.prepare()
}
}
}.collectAsStateWithLifecycle(null)
onDispose {
controllerId.composed = false
if (!controllerId.pictureInPictureActive.value) {
PlaybackServiceClient.removeController(controllerId)
}
}
}
// User pauses and resumes the app. What to do with videos?
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(key1 = lifeCycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
controllerId.composed = true
// if the controller is null, restarts the controller with a new one
// if the controller is not null, just continue playing what the controller was playing
if (controllerId.needsController()) {
if (!onlyOnePreparing.getAndSet(true)) {
scope.launch(Dispatchers.IO) {
Log.d("PlaybackService", "Preparing Video from Resume ${controllerId.id} ${mediaItem.src.videoUri} ")
PlaybackServiceClient.prepareController(
mediaControllerState = controllerId,
videoUri = mediaItem.src.videoUri,
proxyPort = mediaItem.src.proxyPort,
keepPlaying = mediaItem.src.keepPlaying,
context = context,
) { controllerId ->
scope.launch(Dispatchers.Main) {
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
onlyOnePreparing.getAndSet(false)
PlaybackServiceClient.removeController(controllerId)
return@launch
}
// REQUIRED TO BE RUN IN THE MAIN THREAD
// checks again to make sure no other thread has created a controller.
if (!controllerId.isPlaying()) {
if (BackgroundMedia.isPlaying()) {
// There is a video playing, start this one on mute.
controllerId.controller?.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
controllerId.controller?.volume = if (muted) 0f else 1f
}
}
controllerId.controller?.setMediaItem(mediaItem.item)
controllerId.controller?.prepare()
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
onlyOnePreparing.getAndSet(false)
PlaybackServiceClient.removeController(controllerId)
return@launch
}
controllerId.readyToDisplay.value = true
onlyOnePreparing.getAndSet(false)
// checks if the player is still active after requesting to load
if (!controllerId.isActive()) {
PlaybackServiceClient.removeController(controllerId)
return@launch
}
}
}
}
}
}
}
if (event == Lifecycle.Event.ON_PAUSE) {
controllerId.composed = false
PlaybackServiceClient.removeController(controllerId)
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
}
if (controllerId.readyToDisplay.value && controllerId.active.value) {
controllerId.controller?.let {
inner(controllerId)
}
controllerState?.let {
inner(it)
}
}
@@ -42,7 +42,7 @@ fun LoadThumbAndThenVideoView(
contentScale: ContentScale,
nostrUriCallback: String? = null,
accountViewModel: AccountViewModel,
onDialog: ((Boolean) -> Unit)? = null,
onDialog: (() -> Unit)? = null,
) {
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
val context = LocalContext.current
@@ -21,10 +21,8 @@
package com.vitorpamplona.amethyst.service.playback.composable
import android.graphics.Rect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.media3.session.MediaController
import androidx.media3.common.Player
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@@ -34,25 +32,25 @@ class MediaControllerState(
// each composable has an ID.
val id: String = Uuid.random().toString(),
// This is filled after the controller returns from this class
var controller: MediaController? = null,
// this set's the stage to keep playing on the background or not when the user leaves the screen
val pictureInPictureActive: MutableState<Boolean> = mutableStateOf(false),
// this will be false if the screen leaves before the controller connection comes back from the service.
val active: MutableState<Boolean> = mutableStateOf(true),
// this will be set to own when the controller is ready
val readyToDisplay: MutableState<Boolean> = mutableStateOf(false),
// isCurrentlyBeingRendered
var composed: Boolean = false,
var controller: Player,
// visibility onscreen
val visibility: VisibilityData = VisibilityData(),
) {
fun isPlaying() = controller?.isPlaying == true
fun isPlaying() = controller.isPlaying
fun currrentMedia() = controller?.currentMediaItem?.mediaId
fun currrentMedia() = controller.currentMediaItem?.mediaId
fun isActive() = active.value == true
fun toggleMute() {
controller.volume = if (controller.volume == 0f) 1f else 0f
}
fun needsController() = controller == null
fun togglePlayPause() {
if (controller.isPlaying) {
controller.pause()
} else {
controller.play()
}
}
}
@Stable
@@ -20,28 +20,60 @@
*/
package com.vitorpamplona.amethyst.service.playback.composable
import android.content.Context
import android.view.View
import android.widget.FrameLayout
import androidx.annotation.OptIn
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderControlButtons
import androidx.media3.ui.compose.ContentFrame
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
import com.vitorpamplona.amethyst.service.playback.composable.controls.BottomGradientOverlay
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderAnimatedBottomInfo
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons
import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
internal const val SKIP_SECONDS = 10
internal const val SKIP_MILLIS = SKIP_SECONDS * 1000L
internal fun Player.seekBackward() {
seekTo((currentPosition - SKIP_MILLIS).coerceAtLeast(0))
}
internal fun Player.skipForward() {
val newPosition = currentPosition + SKIP_MILLIS
seekTo(if (duration > 0) newPosition.coerceAtMost(duration) else newPosition)
}
private fun getVideoSizeDp(player: Player): Size? {
var videoSize = Size(player.videoSize.width.toFloat(), player.videoSize.height.toFloat())
if (videoSize.width == 0f || videoSize.height == 0f) return null
val par = player.videoSize.pixelWidthHeightRatio
if (par < 1.0) {
videoSize = videoSize.copy(width = videoSize.width * par)
} else if (par > 1.0) {
videoSize = videoSize.copy(height = videoSize.height / par)
}
return videoSize
}
@Composable
@OptIn(UnstableApi::class)
fun RenderVideoPlayer(
@@ -52,74 +84,70 @@ fun RenderVideoPlayer(
contentScale: ContentScale,
borderModifier: Modifier,
videoModifier: Modifier,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: ((Boolean) -> Unit)?,
onDialog: (() -> Unit)? = null,
controllerVisible: MutableState<Boolean> = remember { mutableStateOf(false) },
accountViewModel: AccountViewModel,
) {
val controllerVisible = remember(controllerState) { mutableStateOf(false) }
val containerSize = remember { mutableStateOf(IntSize.Zero) }
val isLive = isLiveStreaming(mediaItem.src.videoUri)
Box(modifier = borderModifier) {
AndroidView(
modifier = videoModifier,
factory = { context: Context ->
PlayerView(context).apply {
player = controllerState.controller
// if we alrady know the size of the frame, this forces the player to stay in the size
layoutParams =
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
)
setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
controllerAutoShow = false
useController = showControls
thumbData?.thumb?.let { defaultArtwork = it }
hideController()
resizeMode =
when (contentScale) {
ContentScale.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
ContentScale.FillWidth -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
ContentScale.Crop -> AspectRatioFrameLayout.RESIZE_MODE_FILL
ContentScale.FillHeight -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
ContentScale.Inside -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
else -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
}
if (showControls) {
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
controllerState.controller?.pause()
innerOnDialog(it)
Box(
modifier =
borderModifier
.onSizeChanged { containerSize.value = it }
.pointerInput(isLive, controllerState) {
detectTapGestures(
onTap = { controllerVisible.value = !controllerVisible.value },
onDoubleTap = { offset ->
if (!isLive) {
val isLeftSide = offset.x < containerSize.value.width / 2
if (isLeftSide) {
controllerState.controller.seekBackward()
} else {
controllerState.controller.skipForward()
}
}
}
setControllerVisibilityListener(
PlayerView.ControllerVisibilityListener { visible ->
controllerVisible.value = visible == View.VISIBLE
onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) }
},
)
}
}
},
},
)
},
) {
ContentFrame(
player = controllerState.controller,
modifier = videoModifier,
surfaceType = SURFACE_TYPE_TEXTURE_VIEW,
contentScale = contentScale,
)
mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) }
if (showControls) {
RenderControlButtons(
mediaItem.src,
controllerState,
controllerVisible,
Modifier.align(Alignment.TopEnd),
accountViewModel,
TopGradientOverlay(
controllerVisible = controllerVisible,
modifier = Modifier.align(Alignment.TopCenter),
)
} else {
controllerState.controller?.volume = 0f
BottomGradientOverlay(
controllerVisible = controllerVisible,
modifier = Modifier.align(Alignment.BottomCenter),
)
RenderTopButtons(
mediaData = mediaItem.src,
controllerState = controllerState,
controllerVisible = controllerVisible,
onZoomClick = onDialog,
modifier = Modifier.align(Alignment.TopEnd),
accountViewModel = accountViewModel,
)
RenderCenterButtons(
controllerState = controllerState,
controllerVisible = controllerVisible,
modifier = Modifier.align(Alignment.Center),
isLiveStream = isLive,
)
RenderAnimatedBottomInfo(controllerState, controllerVisible, Modifier.align(Alignment.BottomCenter))
}
}
}
@@ -65,8 +65,7 @@ fun VideoView(
dimensions: DimensionTag? = null,
blurhash: String? = null,
nostrUriCallback: String? = null,
onDialog: ((Boolean) -> Unit)? = null,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: (() -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false,
) {
@@ -79,7 +78,7 @@ fun VideoView(
Modifier
}
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, onControllerVisibilityChanged, accountViewModel, alwaysShowVideo)
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel)
}
@Composable
@@ -96,11 +95,10 @@ fun VideoView(
dimensions: DimensionTag? = null,
blurhash: String? = null,
nostrUriCallback: String? = null,
onDialog: ((Boolean) -> Unit)? = null,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
onDialog: (() -> Unit)? = null,
alwaysShowVideo: Boolean = false,
showControls: Boolean = true,
accountViewModel: AccountViewModel,
) {
val automaticallyStartPlayback =
remember {
@@ -137,9 +135,8 @@ fun VideoView(
artworkUri = artworkUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog,
automaticallyStartPlayback = automaticallyStartPlayback.value,
onZoom = onDialog,
accountViewModel = accountViewModel,
showControls = showControls,
)
@@ -184,9 +181,8 @@ fun VideoView(
artworkUri = artworkUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog,
automaticallyStartPlayback = automaticallyStartPlayback.value,
onZoom = onDialog,
accountViewModel = accountViewModel,
showControls = showControls,
)
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.service.playback.composable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -46,9 +46,9 @@ fun VideoViewInner(
artworkUri: String? = null,
authorName: String? = null,
nostrUriCallback: String? = null,
automaticallyStartPlayback: State<Boolean>,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: ((Boolean) -> Unit)? = null,
automaticallyStartPlayback: Boolean,
controllerVisible: MutableState<Boolean> = mutableStateOf(true),
onZoom: (() -> Unit)? = null,
accountViewModel: AccountViewModel,
) {
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
@@ -80,8 +80,8 @@ fun VideoViewInner(
contentScale = contentScale,
borderModifier = borderModifier,
videoModifier = videoModifier,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog,
controllerVisible = controllerVisible,
onDialog = onZoom,
accountViewModel = accountViewModel,
)
}
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ZoomOutMap
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Preview
@Composable
fun FullScreenButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
FullScreenButton(
controllerVisible = remember { mutableStateOf(true) },
modifier = Modifier,
) {}
}
}
}
@Composable
fun FullScreenButton(
controllerVisible: MutableState<Boolean>,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
AnimatedVisibility(
visible = controllerVisible.value,
modifier = modifier,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
Box(modifier = PinBottomIconSize) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.7f)
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.background),
)
IconButton(
onClick = onClick,
modifier = Size50Modifier,
) {
Icon(
imageVector = Icons.Default.ZoomOutMap,
contentDescription = stringRes(id = R.string.enter_picture_in_picture),
modifier = modifier,
tint = MaterialTheme.colorScheme.onBackground,
)
}
}
}
}
@@ -0,0 +1,90 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
private val FadeIn = fadeIn()
private val FadeOut = fadeOut()
private val TopGradientColors =
listOf(
Color.Black.copy(alpha = 0.6f),
Color.Black.copy(alpha = 0.3f),
Color.Transparent,
)
private val BottomGradientColors =
listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.4f),
Color.Black.copy(alpha = 0.7f),
)
@Composable
private fun GradientOverlay(
controllerVisible: State<Boolean>,
colors: List<Color>,
height: Dp,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = controllerVisible.value,
modifier = modifier,
enter = FadeIn,
exit = FadeOut,
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.height(height)
.background(brush = Brush.verticalGradient(colors = colors)),
)
}
}
@Composable
fun TopGradientOverlay(
controllerVisible: State<Boolean>,
modifier: Modifier = Modifier,
height: Dp = 80.dp,
) = GradientOverlay(controllerVisible, TopGradientColors, height, modifier)
@Composable
fun BottomGradientOverlay(
controllerVisible: State<Boolean>,
modifier: Modifier = Modifier,
height: Dp = 120.dp,
) = GradientOverlay(controllerVisible, BottomGradientColors, height, modifier)
@@ -0,0 +1,132 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.annotation.OptIn
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.state.rememberProgressStateWithTickCount
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Preview
@Composable
fun HorizontalLinearProgressIndicatorPreview() {
ThemeComparisonColumn {
Column(Modifier.background(BitcoinOrange)) {
HorizontalLinearProgressIndicator(
currentPositionProgress = { 0.00f },
bufferedPositionProgress = { 0.10f },
onLayoutWidthChanged = { widthPx -> },
onSeek = {},
)
HorizontalLinearProgressIndicator(
currentPositionProgress = { 0.10f },
bufferedPositionProgress = { 0.20f },
onLayoutWidthChanged = { widthPx -> },
onSeek = {},
)
HorizontalLinearProgressIndicator(
currentPositionProgress = { 1.00f },
bufferedPositionProgress = { 1.00f },
onLayoutWidthChanged = { widthPx -> },
onSeek = {},
)
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun HorizontalLinearProgressIndicator(
player: Player,
totalTickCount: Int = 0,
) {
var ticks by remember(totalTickCount) { mutableIntStateOf(totalTickCount) }
val progressState = rememberProgressStateWithTickCount(player, ticks)
HorizontalLinearProgressIndicator(
currentPositionProgress = { progressState.currentPositionProgress },
bufferedPositionProgress = { progressState.bufferedPositionProgress },
onLayoutWidthChanged = { widthPx -> if (totalTickCount == 0) ticks = widthPx },
onSeek = { pos -> player.seekTo((pos * player.duration).toLong()) },
)
}
@Composable
private fun HorizontalLinearProgressIndicator(
currentPositionProgress: () -> Float,
bufferedPositionProgress: () -> Float = currentPositionProgress,
onLayoutWidthChanged: (Int) -> Unit = {},
onSeek: (Float) -> Unit,
playedColor: Color = Color.White,
bufferedColor: Color = Color.White.copy(alpha = 0.5f),
unplayedColor: Color = Color.White.copy(alpha = 0.3f),
scrubberColor: Color = playedColor,
rectHeightDp: Dp = 4.dp,
) {
Canvas(
Modifier
.fillMaxWidth()
.padding(horizontal = rectHeightDp * 2.5f)
.pointerInput(Unit) {
detectTapGestures { offset ->
// Capture the exact position
onSeek(offset.x / this.size.width.toFloat())
}
}.padding(vertical = rectHeightDp * 2)
.height(rectHeightDp)
.onSizeChanged { (w, _) -> onLayoutWidthChanged(w) },
) {
val positionX = (currentPositionProgress() * size.width).coerceAtLeast(0f)
val bufferX = (bufferedPositionProgress() * size.width).coerceAtLeast(0f)
drawRect(unplayedColor, size = Size(size.width, size.height))
drawRect(bufferedColor, size = Size(bufferX, size.height))
drawRect(playedColor, size = Size(positionX, size.height))
drawCircle(
color = scrubberColor,
radius = size.height * 2f,
center = Offset(x = positionX, y = size.height / 2.0f),
)
}
}
@@ -41,20 +41,37 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Preview
@Composable
fun MuteButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
MuteButton(
controllerVisible = remember { mutableStateOf(true) },
startingMuteState = true,
modifier = Modifier,
) {}
}
}
}
@Composable
fun MuteButton(
controllerVisible: MutableState<Boolean>,
startingMuteState: Boolean,
modifier: Modifier,
modifier: Modifier = Modifier,
toggle: (Boolean) -> Unit,
) {
val holdOn =
@@ -83,7 +100,7 @@ fun MuteButton(
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.fillMaxSize(0.7f)
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.background),
)
@@ -0,0 +1,195 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PictureInPicture
import androidx.compose.material.icons.filled.SaveAlt
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
private val FadeIn = fadeIn()
private val FadeOut = fadeOut()
@Preview
@Composable
fun OverflowMenuButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
OverflowMenuButton(
showShare = true,
showSave = true,
showPip = true,
onShareClick = {},
onSaveClick = {},
onPipClick = {},
)
}
}
}
@Composable
fun AnimatedOverflowMenuButton(
controllerVisible: State<Boolean>,
modifier: Modifier = Modifier,
showShare: Boolean = true,
showSave: Boolean = true,
showPip: Boolean = false,
onShareClick: () -> Unit,
onSaveClick: () -> Unit,
onPipClick: () -> Unit,
) {
AnimatedVisibility(
visible = controllerVisible.value,
modifier = modifier,
enter = FadeIn,
exit = FadeOut,
) {
OverflowMenuButton(
showShare = showShare,
showSave = showSave,
showPip = showPip,
onShareClick = onShareClick,
onSaveClick = onSaveClick,
onPipClick = onPipClick,
)
}
}
@Composable
fun OverflowMenuButton(
showShare: Boolean,
showSave: Boolean,
showPip: Boolean,
onShareClick: () -> Unit,
onSaveClick: () -> Unit,
onPipClick: () -> Unit,
) {
val menuExpanded = remember { mutableStateOf(false) }
Box(modifier = VolumeBottomIconSize) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.7f)
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.background),
)
IconButton(
onClick = { menuExpanded.value = true },
modifier = Size50Modifier,
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringRes(R.string.more_options),
tint = MaterialTheme.colorScheme.onBackground,
modifier = Size20Modifier,
)
}
DropdownMenu(
expanded = menuExpanded.value,
onDismissRequest = { menuExpanded.value = false },
containerColor = Color.Black.copy(alpha = 0.85f),
) {
if (showShare) {
DropdownMenuItem(
text = { Text(stringRes(R.string.share_or_save), color = Color.White) },
onClick = {
menuExpanded.value = false
onShareClick()
},
leadingIcon = {
Icon(
Icons.Default.Share,
contentDescription = null,
tint = Color.White,
)
},
)
}
if (showSave) {
DropdownMenuItem(
text = { Text(stringRes(R.string.save_to_gallery), color = Color.White) },
onClick = {
menuExpanded.value = false
onSaveClick()
},
leadingIcon = {
Icon(
Icons.Default.SaveAlt,
contentDescription = null,
tint = Color.White,
)
},
)
}
if (showPip) {
DropdownMenuItem(
text = { Text(stringRes(R.string.picture_in_picture), color = Color.White) },
onClick = {
menuExpanded.value = false
onPipClick()
},
leadingIcon = {
Icon(
Icons.Default.PictureInPicture,
contentDescription = null,
tint = Color.White,
)
},
)
}
}
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import android.app.AppOpsManager
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -31,23 +33,54 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.ui.note.EnablePiP
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Preview
@Composable
fun PictureInPictureButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
PictureInPictureButton(
controllerVisible = remember { mutableStateOf(true) },
modifier = Modifier,
) {}
}
}
}
@Composable
fun PictureInPictureButton(
controllerVisible: MutableState<Boolean>,
modifier: Modifier,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val context = LocalContext.current
val isAllowed =
remember {
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
val mode =
appOps.checkOpNoThrow(
AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
android.os.Process.myUid(),
context.packageName,
)
mode == AppOpsManager.MODE_ALLOWED
}
AnimatedVisibility(
visible = controllerVisible.value,
visible = isAllowed && controllerVisible.value,
modifier = modifier,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
@@ -56,7 +89,7 @@ fun PictureInPictureButton(
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.fillMaxSize(0.7f)
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.background),
)
@@ -0,0 +1,130 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PlayIconSize
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Preview
@Composable
fun AnimatedPlayButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
AnimatedPlayPauseButton(
controllerVisible = remember { mutableStateOf(true) },
isPlaying = true,
modifier = Modifier,
) { }
}
}
}
@Preview
@Composable
fun AnimatedPauseButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
AnimatedPlayPauseButton(
controllerVisible = remember { mutableStateOf(true) },
isPlaying = false,
modifier = Modifier,
) { }
}
}
}
@Composable
fun AnimatedPlayPauseButton(
controllerVisible: State<Boolean>,
modifier: Modifier = Modifier,
isPlaying: Boolean,
onClick: () -> Unit,
) {
AnimatedVisibility(
visible = controllerVisible.value,
modifier = modifier,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
PlayPauseButton(isPlaying, onClick)
}
}
@Composable
fun PlayPauseButton(
isPlaying: Boolean,
onClick: () -> Unit,
) {
Box(modifier = PlayIconSize, contentAlignment = Alignment.Center) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.background(MaterialTheme.colorScheme.background),
)
IconButton(
onClick = onClick,
modifier = Modifier.size(80.dp),
) {
if (!isPlaying) {
Icon(
imageVector = Icons.Default.PlayArrow,
modifier = Size50Modifier,
contentDescription = stringRes(R.string.play),
)
} else {
Icon(
imageVector = Icons.Default.Pause,
modifier = Size50Modifier,
contentDescription = stringRes(R.string.pause),
)
}
}
}
}
@@ -0,0 +1,169 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.state.rememberPlaybackSpeedState
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Preview
@Composable
fun PlaybackSpeedPopUpButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
PlaybackSpeedPopUpButton(
playbackSpeed = 0.50f,
updatePlaybackSpeed = {},
modifier = Modifier,
)
}
}
}
@Preview
@Composable
fun BottomDialogOfChoicesBodyPreview() {
ThemeComparisonRow {
Box(Modifier.background(BitcoinOrange)) {
BottomDialogOfChoicesBody(
currentSpeed = 0.50f,
choices = persistentListOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f),
onDismissRequest = {},
onSelectChoice = {},
)
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun PlaybackSpeedPopUpButton(
player: Player,
modifier: Modifier = Modifier,
speedSelection: ImmutableList<Float> = persistentListOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f),
) {
val state = rememberPlaybackSpeedState(player)
if (state.isEnabled) {
PlaybackSpeedPopUpButton(
state.playbackSpeed,
state::updatePlaybackSpeed,
modifier,
speedSelection,
)
}
}
@OptIn(UnstableApi::class)
@Composable
fun PlaybackSpeedPopUpButton(
playbackSpeed: Float,
updatePlaybackSpeed: (speed: Float) -> Unit,
modifier: Modifier = Modifier,
speedSelection: ImmutableList<Float> = persistentListOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f),
) {
var openDialog by remember { mutableStateOf(false) }
Box {
Text(
text = "%.1fx".format(playbackSpeed),
color = Color.White,
modifier = modifier.clickable(onClick = { openDialog = true }),
style = MaterialTheme.typography.labelLarge,
)
if (openDialog) {
Popup(
alignment = Alignment.BottomCenter,
onDismissRequest = { openDialog = false },
properties = PopupProperties(focusable = true),
) {
BottomDialogOfChoicesBody(
currentSpeed = playbackSpeed,
choices = speedSelection,
onDismissRequest = { openDialog = false },
onSelectChoice = updatePlaybackSpeed,
)
}
}
}
}
@Composable
private fun BottomDialogOfChoicesBody(
currentSpeed: Float,
choices: ImmutableList<Float>,
onDismissRequest: () -> Unit,
onSelectChoice: (Float) -> Unit,
) {
val colors =
ButtonDefaults.textButtonColors().copy(
contentColor = MaterialTheme.colorScheme.onBackground,
)
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
choices.forEach { speed ->
TextButton(
colors = colors,
onClick = {
onSelectChoice(speed)
onDismissRequest()
},
) {
var fontWeight = FontWeight(400)
if (speed == currentSpeed) {
fontWeight = FontWeight(1000)
}
Text("%.1fx".format(speed), fontWeight = fontWeight)
}
}
}
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.ui.compose.state.rememberProgressStateWithTickInterval
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@OptIn(UnstableApi::class)
@Preview
@Composable
fun PositionAndDurationTextPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
PositionAndDurationText(
133000,
1000000,
modifier = Modifier,
)
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun PositionAndDurationText(
player: Player,
modifier: Modifier = Modifier,
) {
val state = rememberProgressStateWithTickInterval(player, tickIntervalMs = 1000L)
PositionAndDurationText(
state.currentPositionMs,
state.durationMs,
modifier,
)
}
@UnstableApi
@Composable
fun PositionAndDurationText(
positionMs: Long,
durationMs: Long,
modifier: Modifier = Modifier,
) {
val text = "${Util.getStringForTime(positionMs)} / ${Util.getStringForTime(durationMs)}"
Text(
text = text,
color = Color.White,
style = MaterialTheme.typography.labelLarge,
modifier = modifier,
)
}
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
@OptIn(UnstableApi::class)
@Composable
fun RenderAnimatedBottomInfo(
controllerState: MediaControllerState,
controllerVisible: MutableState<Boolean>,
modifier: Modifier,
) {
AnimatedVisibility(
visible = controllerVisible.value,
modifier = modifier,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
// Button panel controls
Column(modifier.fillMaxWidth()) {
HorizontalLinearProgressIndicator(controllerState.controller)
RenderBottomButtonLine(controllerState)
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun RenderBottomButtonLine(controllerState: MediaControllerState) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 5.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
PositionAndDurationText(controllerState.controller)
Spacer(Modifier.weight(1f))
PlaybackSpeedPopUpButton(controllerState.controller)
}
}
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.seekBackward
import com.vitorpamplona.amethyst.service.playback.composable.skipForward
import kotlinx.coroutines.delay
@OptIn(UnstableApi::class)
@Composable
fun RenderCenterButtons(
controllerState: MediaControllerState,
controllerVisible: MutableState<Boolean>,
modifier: Modifier,
isLiveStream: Boolean = false,
) {
val state = rememberPlayPauseButtonState(controllerState.controller)
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(32.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (!isLiveStream) {
AnimatedSkipButton(controllerVisible = controllerVisible, isForward = false) {
controllerState.controller.seekBackward()
}
}
AnimatedPlayPauseButton(controllerVisible, Modifier, !state.showPlay) {
state.onClick()
}
if (!isLiveStream) {
AnimatedSkipButton(controllerVisible = controllerVisible, isForward = true) {
controllerState.controller.skipForward()
}
}
}
if (!state.showPlay) {
LaunchedEffect(state.showPlay) {
delay(3000)
controllerVisible.value = false
}
}
}
@@ -1,82 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size110dp
import com.vitorpamplona.amethyst.ui.theme.Size165dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
@Composable
fun RenderControlButtons(
mediaData: MediaItemData,
controllerState: MediaControllerState,
controllerVisible: MutableState<Boolean>,
buttonPositionModifier: Modifier,
accountViewModel: AccountViewModel,
) {
MuteButton(
controllerVisible,
(controllerState.controller?.volume ?: 0f) < 0.001,
buttonPositionModifier,
) { mute: Boolean ->
// makes the new setting the default for new creations.
DEFAULT_MUTED_SETTING.value = mute
controllerState.controller?.volume = if (mute) 0f else 1f
}
val context = LocalContext.current.getActivity()
PictureInPictureButton(
controllerVisible,
buttonPositionModifier.padding(end = Size55dp),
) {
PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context)
}
if (!isLiveStreaming(mediaData.videoUri)) {
AnimatedSaveButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { context ->
accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
}
AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size165dp)) { popupExpanded, toggle ->
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle, content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri))
}
} else {
AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { popupExpanded, toggle ->
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle, content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri))
}
}
}
@@ -0,0 +1,185 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import android.content.pm.PackageManager
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Preview
@Composable
fun RenderTopButtonsPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
RenderTopButtons(
mediaData = MediaItemData("http://test.mp4"),
controllerVisible = remember { mutableStateOf(true) },
startingMuteState = false,
isLive = false,
pipSupported = true,
onMuteClick = {},
onPictureInPictureClick = {},
onZoomClick = {},
modifier = Modifier,
accountViewModel = mockAccountViewModel(),
)
}
}
}
@Composable
fun RenderTopButtons(
mediaData: MediaItemData,
controllerState: MediaControllerState,
controllerVisible: MutableState<Boolean>,
onZoomClick: (() -> Unit)?,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
val context = LocalContext.current
val isLive = isLiveStreaming(mediaData.videoUri)
val pipSupported =
remember {
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
}
RenderTopButtons(
mediaData = mediaData,
controllerVisible = controllerVisible,
startingMuteState = controllerState.controller.volume < 0.001,
isLive = isLive,
pipSupported = pipSupported,
onMuteClick = { mute ->
DEFAULT_MUTED_SETTING.value = mute
controllerState.controller.volume = if (mute) 0f else 1f
},
onPictureInPictureClick = {
controllerState.controller.pause()
PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context.getActivity())
},
onZoomClick =
onZoomClick?.let {
{
controllerState.controller.pause()
it()
}
},
modifier = modifier,
accountViewModel = accountViewModel,
)
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun RenderTopButtons(
mediaData: MediaItemData,
controllerVisible: MutableState<Boolean>,
startingMuteState: Boolean,
isLive: Boolean,
pipSupported: Boolean,
onMuteClick: (Boolean) -> Unit,
onPictureInPictureClick: () -> Unit,
onZoomClick: (() -> Unit)?,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
val shareDialogVisible = remember { mutableStateOf(false) }
val saveAction =
rememberSaveMediaAction { context ->
accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
}
LaunchedEffect(controllerVisible.value) {
if (!controllerVisible.value) shareDialogVisible.value = false
}
Row(modifier) {
if (onZoomClick != null) {
FullScreenButton(
controllerVisible = controllerVisible,
onClick = onZoomClick,
)
}
MuteButton(
controllerVisible = controllerVisible,
startingMuteState = startingMuteState,
toggle = onMuteClick,
)
Box {
AnimatedOverflowMenuButton(
controllerVisible = controllerVisible,
showShare = true,
showSave = !isLive,
showPip = pipSupported,
onShareClick = { shareDialogVisible.value = true },
onSaveClick = saveAction,
onPipClick = onPictureInPictureClick,
)
if (shareDialogVisible.value) {
ShareMediaAction(
popupExpanded = shareDialogVisible,
videoUri = mediaData.videoUri,
postNostrUri = mediaData.callbackUri,
blurhash = null,
dim = null,
hash = null,
mimeType = mediaData.mimeType,
onDismiss = { shareDialogVisible.value = false },
content =
MediaUrlVideo(
url = mediaData.videoUri,
mimeType = mediaData.mimeType,
artworkUri = mediaData.artworkUri,
authorName = mediaData.authorName,
description = mediaData.title,
uri = mediaData.callbackUri,
),
accountViewModel = accountViewModel,
)
}
}
}
}
@@ -38,26 +38,43 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.coroutines.launch
@Preview
@Composable
fun AnimatedSaveButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
AnimatedSaveButton(
controllerVisible = remember { mutableStateOf(true) },
modifier = Modifier,
) {}
}
}
}
@Composable
fun AnimatedSaveButton(
controllerVisible: State<Boolean>,
modifier: Modifier,
modifier: Modifier = Modifier,
onSaveClick: (localContext: Context) -> Unit,
) {
AnimatedVisibility(
@@ -70,46 +87,46 @@ fun AnimatedSaveButton(
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun rememberSaveMediaAction(onSaveClick: (Context) -> Unit): () -> Unit {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val writeStoragePermission =
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
if (isGranted) onSaveClick(context)
}
return {
scope.launch {
Toast
.makeText(
context,
stringRes(context, R.string.video_download_has_started_toast),
Toast.LENGTH_SHORT,
).show()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermission.status.isGranted) {
onSaveClick(context)
} else {
writeStoragePermission.launchPermissionRequest()
}
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) {
val saveAction = rememberSaveMediaAction(onSaveClick)
Box(modifier = PinBottomIconSize) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.fillMaxSize(0.7f)
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.background),
)
val localContext = LocalContext.current
val writeStoragePermissionState =
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
if (isGranted) {
onSaveClick(localContext)
}
}
val scope = rememberCoroutineScope()
IconButton(
onClick = {
scope.launch {
Toast
.makeText(
localContext,
stringRes(localContext, R.string.video_download_has_started_toast),
Toast.LENGTH_SHORT,
).show()
}
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
writeStoragePermissionState.status.isGranted
) {
onSaveClick(localContext)
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
onClick = saveAction,
modifier = Size50Modifier,
) {
Icon(
@@ -40,16 +40,32 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Preview
@Composable
fun AnimatedShareButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
AnimatedShareButton(
controllerVisible = remember { mutableStateOf(true) },
modifier = Modifier,
) { _, _ -> }
}
}
}
@Composable
fun AnimatedShareButton(
controllerVisible: State<Boolean>,
modifier: Modifier,
modifier: Modifier = Modifier,
innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit,
) {
AnimatedVisibility(
@@ -64,13 +80,13 @@ fun AnimatedShareButton(
@Composable
fun ShareButton(innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit) {
Box(modifier = PinBottomIconSize) {
Box(modifier = PinBottomIconSize, contentAlignment = Alignment.Center) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.background),
modifier =
Modifier
.clip(CircleShape)
.fillMaxSize(0.7f)
.background(MaterialTheme.colorScheme.background),
)
val popupExpanded = remember { mutableStateOf(false) }
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Forward10
import androidx.compose.material.icons.filled.Replay10
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.playback.composable.SKIP_SECONDS
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
private val FadeIn = fadeIn()
private val FadeOut = fadeOut()
@Preview
@Composable
fun SkipBackButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
SkipButton(isForward = false, onClick = {})
}
}
}
@Preview
@Composable
fun SkipForwardButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
SkipButton(isForward = true, onClick = {})
}
}
}
@Composable
fun AnimatedSkipButton(
controllerVisible: State<Boolean>,
isForward: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
AnimatedVisibility(
visible = controllerVisible.value,
modifier = modifier,
enter = FadeIn,
exit = FadeOut,
) {
SkipButton(isForward = isForward, onClick = onClick)
}
}
@Composable
fun SkipButton(
isForward: Boolean,
onClick: () -> Unit,
) {
val icon = if (isForward) Icons.Default.Forward10 else Icons.Default.Replay10
val label = if (isForward) stringRes(R.string.skip_forward, SKIP_SECONDS) else stringRes(R.string.skip_back, SKIP_SECONDS)
IconButton(onClick = onClick, modifier = Modifier.size(48.dp)) {
Icon(imageVector = icon, contentDescription = label, tint = Color.White, modifier = Modifier.size(32.dp))
}
}
@@ -30,7 +30,6 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
@@ -40,7 +39,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerSta
import kotlin.math.abs
// This keeps the position of all visible videos in the current screen.
val trackingVideos = mutableListOf<MediaControllerState>()
val trackingVideos = mutableSetOf<MediaControllerState>()
/**
* This function selects only one Video to be active. The video that is closest to the center of the
@@ -52,17 +51,21 @@ fun VideoPlayerActiveMutex(
inner: @Composable (Modifier, MutableState<Boolean>) -> Unit,
) {
// Is the current video the closest to the center?
val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf<Boolean>(false) }
val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf(false) }
// Keep track of all available videos.
DisposableEffect(key1 = controller) {
trackingVideos.add(controller)
onDispose { trackingVideos.remove(controller) }
onDispose {
trackingVideos.remove(controller)
}
}
val view = LocalView.current
val videoModifier =
remember(controller) {
Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges { bounds, distanceToCenter ->
Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges(view) { bounds, distanceToCenter ->
controller.visibility.bounds = bounds
controller.visibility.distanceToCenter = distanceToCenter
@@ -93,15 +96,14 @@ fun VideoPlayerActiveMutex(
inner(videoModifier, isClosestToTheCenterOfTheScreen)
}
fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Rect, Float?) -> Unit): Modifier =
composed {
val view = LocalView.current
onGloballyPositioned { coordinates ->
val bounds = coordinates.boundsInWindow()
val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())
onVisiblePosition(boundRect, coordinates.getDistanceToVertCenterIfVisible(boundRect, view))
}
fun Modifier.onVisiblePositionChanges(
view: View,
onVisiblePosition: (Rect, Float?) -> Unit,
): Modifier =
onGloballyPositioned { coordinates ->
val bounds = coordinates.boundsInWindow()
val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())
onVisiblePosition(boundRect, coordinates.getDistanceToVertCenterIfVisible(boundRect, view))
}
fun LayoutCoordinates.getDistanceToVertCenterIfVisible(
@@ -35,7 +35,6 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import com.linc.audiowaveform.infiniteLinearGradient
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
@@ -56,31 +55,25 @@ fun Waveform(
val restartFlow = remember { mutableIntStateOf(0) }
val myController = mediaControllerState.controller
// Keeps the screen on while playing and viewing videos.
if (myController != null) {
DisposableEffect(key1 = myController) {
val listener =
object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
// doesn't consider the mutex because the screen can turn off if the video
// being played in the mutex is not visible.
if (isPlaying) {
restartFlow.intValue += 1
}
DisposableEffect(key1 = mediaControllerState.controller) {
val listener =
object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
// doesn't consider the mutex because the screen can turn off if the video
// being played in the mutex is not visible.
if (isPlaying) {
restartFlow.intValue += 1
}
}
}
myController.addListener(listener)
onDispose { myController.removeListener(listener) }
}
mediaControllerState.controller.addListener(listener)
onDispose { mediaControllerState.controller.removeListener(listener) }
}
LaunchedEffect(key1 = restartFlow.intValue) {
mediaControllerState.controller?.let {
pollCurrentDuration(it).collect { value -> waveformProgress.floatValue = value }
}
pollCurrentDuration(mediaControllerState.controller).collect { value -> waveformProgress.floatValue = value }
}
LaunchedEffect(Unit) {
@@ -90,7 +83,7 @@ fun Waveform(
}
}
private fun pollCurrentDuration(controller: MediaController) =
private fun pollCurrentDuration(controller: Player) =
flow {
while (controller.currentPosition <= controller.duration) {
emit(controller.currentPosition / controller.duration.toFloat())
@@ -20,36 +20,43 @@
*/
package com.vitorpamplona.amethyst.service.playback.pip
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
import kotlinx.coroutines.flow.MutableStateFlow
object BackgroundMedia {
// background playing mutex.
val bgInstance = MutableStateFlow<MediaControllerState?>(null)
var bgInstance: MediaControllerState? = null
fun hasInstance() = bgInstance.value != null
fun hasInstance() = bgInstance != null
fun isPlaying() = bgInstance.value?.isPlaying() == true
fun isPlaying() = bgInstance?.isPlaying() == true
fun isMutex(controller: MediaControllerState): Boolean = controller.id == bgInstance.value?.id
fun isMutex(controller: MediaControllerState): Boolean = controller.id == bgInstance?.id
fun hasBackgroundButNot(mediaControllerState: MediaControllerState): Boolean = hasInstance() && !isMutex(mediaControllerState)
fun removeBackgroundControllerAndReleaseIt() {
bgInstance.value?.let {
PlaybackServiceClient.removeController(it)
bgInstance.tryEmit(null)
}
bgInstance = null
}
fun switchKeepPlaying(mediaControllerState: MediaControllerState) {
bgInstance.tryEmit(mediaControllerState)
bgInstance = mediaControllerState
}
fun clearBackground(mediaControllerState: MediaControllerState) {
if (bgInstance.value == mediaControllerState) {
bgInstance.tryEmit(null)
if (bgInstance == mediaControllerState) {
bgInstance = null
}
}
}
@Composable
fun RegisterBackgroundMedia(controllerState: MediaControllerState) {
DisposableEffect(controllerState) {
BackgroundMedia.switchKeepPlaying(controllerState)
onDispose {
BackgroundMedia.clearBackground(controllerState)
}
}
}
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.pip
import android.app.Activity
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.core.util.Consumer
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.ui.components.getActivity
import kotlinx.coroutines.launch
@Composable
fun rememberVideoDataFromIntents(): MutableState<MediaItemData?> {
val activity = LocalContext.current.getActivity()
val videoData =
remember {
mutableStateOf(activity.processIntentForPiP(activity.intent))
}
val scope = rememberCoroutineScope()
DisposableEffect(activity) {
val consumer =
Consumer<Intent> { intent ->
scope.launch {
videoData.value = activity.processIntentForPiP(intent)
}
}
activity.addOnNewIntentListener(consumer)
onDispose {
activity.removeOnNewIntentListener(consumer)
}
}
return videoData
}
fun Activity.processIntentForPiP(intent: Intent): MediaItemData? {
val videoDataOnCreation = IntentExtras.loadBundle(intent.extras)
val bounds = IntentExtras.loadBounds(intent.extras)
val ratio = videoDataOnCreation?.aspectRatio ?: videoDataOnCreation?.videoUri?.let { MediaAspectRatioCache.get(it) }
val isPlaying = true
val isMuted = DEFAULT_MUTED_SETTING.value
if (!isInPictureInPictureMode) {
enterPictureInPictureMode(makePipParams(isPlaying, isMuted, ratio, bounds))
} else {
setPictureInPictureParams(makePipParams(isPlaying, isMuted, ratio, bounds))
}
return videoDataOnCreation
}
@@ -20,20 +20,11 @@
*/
package com.vitorpamplona.amethyst.service.playback.pip
import android.app.Activity
import android.app.PictureInPictureParams
import android.graphics.Rect
import android.os.Build
import android.util.Rational
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.util.Consumer
import com.vitorpamplona.amethyst.ui.components.getActivity
fun makePipParams(
ratio: Float?,
@@ -49,18 +40,25 @@ fun makePipParams(
ratio?.let { setAspectRatio(Rational((it * 1000).toInt(), 1000)) }
}.build()
@Composable
fun rememberIsInPipMode(): Boolean {
val activity = LocalContext.current.getActivity()
var pipMode by remember { mutableStateOf(activity.isInPictureInPictureMode) }
DisposableEffect(activity) {
val observer =
Consumer<PictureInPictureModeChangedInfo> { info ->
pipMode = info.isInPictureInPictureMode
fun Activity.makePipParams(
isPlaying: Boolean,
isMuted: Boolean,
ratio: Float?,
bounds: Rect?,
): PictureInPictureParams =
PictureInPictureParams
.Builder()
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setAutoEnterEnabled(true)
}
activity.addOnPictureInPictureModeChangedListener(observer)
onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) }
}
return pipMode
}
setActions(
listOf(
createPlayPauseAction(this@makePipParams, isPlaying),
createMuteAction(this@makePipParams, isMuted),
),
)
bounds?.let { setSourceRectHint(bounds) }
ratio?.let { setAspectRatio(Rational((it * 1000).toInt(), 1000)) }
}.build()
@@ -23,15 +23,18 @@ package com.vitorpamplona.amethyst.service.playback.pip
import android.app.ActivityOptions
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.OptIn
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
class PipVideoActivity : ComponentActivity() {
@@ -39,22 +42,22 @@ class PipVideoActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
var videoData = IntentExtras.loadBundle(this.intent.extras)
val bounds = IntentExtras.loadBounds(this.intent.extras)
val ratio = videoData?.aspectRatio ?: videoData?.videoUri?.let { MediaAspectRatioCache.get(it) }
enterPipMode(ratio, bounds)
setContent {
PipVideo()
}
}
val videoData by rememberVideoDataFromIntents()
videoData?.let { mediaItemData ->
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
val muted = remember(mediaItemData) { DEFAULT_MUTED_SETTING.value }
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration,
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
GetMediaItem(mediaItemData) { mediaItem ->
GetVideoController(mediaItem, muted, true) { controllerState ->
RegisterBackgroundMedia(controllerState)
RegisterControllerReceiver(controllerState)
WatchControllerForActions(mediaItemData, controllerState)
RenderPipVideo(controllerState, mediaItemData.waveformData)
}
}
}
}
}
override fun onStop() {
@@ -67,11 +70,6 @@ class PipVideoActivity : ComponentActivity() {
super.finish()
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
finishAndRemoveTask()
}
companion object {
fun callIn(
videoData: MediaItemData,
@@ -86,13 +84,10 @@ class PipVideoActivity : ComponentActivity() {
} else {
ActivityOptions.makeBasic()
}
options.setLaunchBounds(videoBounds)
context.startActivity(
Intent(context, PipVideoActivity::class.java).apply {
Intent(context.applicationContext, PipVideoActivity::class.java).apply {
putExtras(IntentExtras.createBundle(videoData, videoBounds))
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
},
options.toBundle(),
)
@@ -20,109 +20,57 @@
*/
package com.vitorpamplona.amethyst.service.playback.pip
import android.content.Context
import android.content.Intent
import android.content.Context.RECEIVER_EXPORTED
import android.os.Build
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.util.Consumer
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import androidx.media3.ui.compose.ContentFrame
import androidx.media3.ui.compose.state.rememberMuteButtonState
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.theme.VoiceHeightModifier
@Composable
fun PipVideo() {
val activity = LocalContext.current.getActivity()
var videoData by remember(activity) {
mutableStateOf(IntentExtras.loadBundle(activity.intent.extras))
}
DisposableEffect(activity) {
val consumer =
Consumer<Intent> { intent ->
videoData = IntentExtras.loadBundle(intent.extras)
val bounds = IntentExtras.loadBounds(intent.extras)
val ratio = videoData?.aspectRatio ?: videoData?.videoUri?.let { MediaAspectRatioCache.get(it) }
activity.enterPipMode(ratio, bounds)
}
activity.addOnNewIntentListener(consumer)
onDispose { activity.removeOnNewIntentListener(consumer) }
}
videoData?.let {
GetMediaItem(it) { mediaItem ->
GetVideoController(mediaItem, false) { controller ->
PipVideo(controller, it.waveformData)
}
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun PipVideo(
fun RenderPipVideo(
controller: MediaControllerState,
waveformData: WaveformData?,
) {
DisposableEffect(controller) {
BackgroundMedia.switchKeepPlaying(controller)
onDispose {
BackgroundMedia.clearBackground(controller)
}
}
val ratio =
controller.currrentMedia()?.let {
MediaAspectRatioCache.get(it)
}
val modifier =
if (ratio != null) {
Modifier.aspectRatio(ratio)
} else {
Modifier
remember {
val ratio =
controller.currrentMedia()?.let {
MediaAspectRatioCache.get(it)
}
if (ratio != null) {
Modifier.aspectRatio(ratio)
} else {
Modifier
}
}
Box(modifier, contentAlignment = Alignment.Center) {
AndroidView(
modifier = Modifier,
factory = { context: Context ->
PlayerView(context).apply {
clipToOutline = true
player = controller.controller
setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
controllerAutoShow = false
useController = false
hideController()
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL
controller.controller?.playWhenReady = true
}
},
ContentFrame(
player = controller.controller,
keepContentOnReset = true,
contentScale = ContentScale.Crop,
)
Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) {
@@ -130,3 +78,51 @@ fun PipVideo(
}
}
}
@Composable
fun RegisterControllerReceiver(controllerState: MediaControllerState) {
val context = LocalContext.current
// Use DisposableEffect to manage the receiver's lifecycle
DisposableEffect(context) {
val receiver =
ActionReceiver(
onMute = controllerState::toggleMute,
onPlayPause = controllerState::togglePlayPause,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(receiver, receiver.filterMute, RECEIVER_EXPORTED)
context.registerReceiver(receiver, receiver.filterPlayPause, RECEIVER_EXPORTED)
} else {
context.registerReceiver(receiver, receiver.filterMute)
context.registerReceiver(receiver, receiver.filterPlayPause)
}
onDispose {
context.unregisterReceiver(receiver)
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun WatchControllerForActions(
mediaItemData: MediaItemData,
controllerState: MediaControllerState,
) {
val playPauseState = rememberPlayPauseButtonState(controllerState.controller)
val muteState = rememberMuteButtonState(controllerState.controller)
val activity = LocalContext.current.getActivity()
LaunchedEffect(activity, playPauseState.showPlay, muteState.showMuted) {
activity.setPictureInPictureParams(
activity.makePipParams(
isPlaying = !playPauseState.showPlay,
isMuted = !muteState.showMuted,
ratio = mediaItemData.aspectRatio,
bounds = null,
),
)
}
}
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.pip
import android.app.PendingIntent
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.Icon
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
const val ACTION_MUTE = "com.vitorpamplona.amethyst.MUTE"
const val ACTION_PLAY_PAUSE = "com.vitorpamplona.amethyst.PLAY_PAUSE"
class ActionReceiver(
val onMute: () -> Unit,
val onPlayPause: () -> Unit,
) : BroadcastReceiver() {
val filterMute = IntentFilter(ACTION_MUTE)
val filterPlayPause = IntentFilter(ACTION_PLAY_PAUSE)
override fun onReceive(
context: Context,
intent: Intent,
) {
when (intent.action) {
ACTION_MUTE -> onMute()
ACTION_PLAY_PAUSE -> onPlayPause()
}
}
}
fun createMuteAction(
context: Context,
isMuted: Boolean,
): RemoteAction {
val icon =
if (isMuted) {
Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_volume_up)
} else {
Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_volume_off)
}
val title = if (isMuted) stringRes(context, R.string.muted_button) else stringRes(context, R.string.mute_button)
val intent =
PendingIntent.getBroadcast(
context,
0,
Intent(ACTION_MUTE),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return RemoteAction(icon, title, title, intent)
}
fun createPlayPauseAction(
context: Context,
isPlaying: Boolean,
): RemoteAction {
val icon =
if (!isPlaying) {
Icon.createWithResource(context, androidx.media3.ui.compose.material3.R.drawable.media3_icon_play)
} else {
Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_pause)
}
val title = if (!isPlaying) stringRes(context, R.string.play) else stringRes(context, R.string.pause)
val intent =
PendingIntent.getBroadcast(
context,
0,
Intent(ACTION_PLAY_PAUSE),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return RemoteAction(icon, title, title, intent)
}
@@ -45,7 +45,7 @@ class ExoPlayerPool(
// Exists to avoid exceptions stopping the coroutine
val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
Log.e("BundledInsert", "Caught exception: ${throwable.message}", throwable)
Log.e("PlaybackService", "Caught exception: ${throwable.message}", throwable)
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler)
@@ -75,15 +75,19 @@ class ExoPlayerPool(
suspend fun releasePlayer(player: ExoPlayer) {
mutex.withLock {
player.pause()
player.stop()
player.clearMediaItems()
if (playerPool.size < poolSize) {
if (!playerPool.contains(player)) {
playerPool.add(player)
if (!player.isReleased) {
player.pause()
player.stop()
player.clearVideoSurface()
player.clearMediaItems()
if (playerPool.size < poolSize) {
if (!playerPool.contains(player)) {
playerPool.add(player)
}
} else {
player.release() // Release if pool is full.
}
} else {
player.release() // Release if pool is full.
}
}
}
@@ -36,7 +36,6 @@ import androidx.media3.session.MediaSession
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -99,10 +98,11 @@ class MediaSessionPool(
.Builder(context, exoPlayerPool.acquirePlayer(context))
.apply {
setBitmapLoader(
DataSourceBitmapLoader(
DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get(),
OkHttpDataSource.Factory(okHttpClient),
),
DataSourceBitmapLoader
.Builder(context)
.setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get())
.setDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
.build(),
)
setId(id)
setCallback(globalCallback)
@@ -125,11 +125,9 @@ class MediaSessionPool(
session.player.removeListener(listener.playerListener)
}
cache.remove(session.id)
playingMap.remove(session.id)
cache.remove(session.id)
session.release()
exoPlayerPool.releasePlayerAsync(session.player as ExoPlayer)
cleanupUnused()
}
@@ -144,7 +142,6 @@ class MediaSessionPool(
// but not connected yet.
// delay(10000)
snap.values.forEach {
Log.d("MediaSessionPoll", "Clean Up Unused ${it.session.connectedControllers.size} ${it.session.id}")
if (it.session.connectedControllers.isEmpty()) {
releaseSession(it.session)
counter++
@@ -117,7 +117,7 @@ class PlaybackService : MediaSessionService() {
// if nothing is pl
if (playing.isEmpty() && BackgroundMedia.hasInstance()) {
BackgroundMedia.bgInstance.value?.id?.let { id ->
BackgroundMedia.bgInstance?.id?.let { id ->
(poolNoProxy?.getSession(id) ?: poolWithProxy?.getSession(id))?.let {
super.onUpdateNotification(it, startInForegroundRequired)
}
@@ -126,7 +126,7 @@ class PlaybackService : MediaSessionService() {
}
playing.forEachIndexed { idx, it ->
if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance.value?.id) {
if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance?.id) {
super.onUpdateNotification(it.session, startInForegroundRequired)
return
}
@@ -27,91 +27,77 @@ import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
object PlaybackServiceClient {
val executorService: ExecutorService = Executors.newCachedThreadPool()
fun removeController(mediaControllerState: MediaControllerState) {
mediaControllerState.active.value = false
mediaControllerState.readyToDisplay.value = false
val myController = mediaControllerState.controller
// release when can
if (myController != null) {
mediaControllerState.controller = null
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.Main) {
// myController.pause()
// myController.stop()
myController.release()
Log.d("PlaybackService", "Releasing Video $mediaControllerState")
}
}
}
fun prepareController(
mediaControllerState: MediaControllerState,
@OptIn(ExperimentalUuidApi::class)
fun controllerAsFlow(
videoUri: String,
proxyPort: Int? = 0,
keepPlaying: Boolean = true,
context: Context,
onReady: (MediaControllerState) -> Unit,
) {
) = callbackFlow {
val appContext = context.applicationContext
val id = Uuid.random().toString()
mediaControllerState.active.value = true
try {
val bundle =
Bundle().apply {
// link the id with the client's id to make sure it can return the
// same session on background media.
putString("id", mediaControllerState.id)
putBoolean("keepPlaying", keepPlaying)
proxyPort?.let {
putInt("proxyPort", it)
}
val bundle =
Bundle().apply {
// link the id with the client's id to make sure it can return the
// same session on background media.
putString("id", id)
putBoolean("keepPlaying", keepPlaying)
proxyPort?.let {
putInt("proxyPort", it)
}
}
val session = SessionToken(appContext, ComponentName(appContext, PlaybackService::class.java))
val session = SessionToken(appContext, ComponentName(appContext, PlaybackService::class.java))
val controllerFuture =
MediaController
.Builder(appContext, session)
.setConnectionHints(bundle)
.buildAsync()
val controllerFuture =
MediaController
.Builder(appContext, session)
.setConnectionHints(bundle)
.buildAsync()
Log.d("PlaybackService", "Preparing Controller ${mediaControllerState.id} $videoUri")
Log.d("PlaybackService", "Preparing Controller $id $videoUri")
controllerFuture.addListener(
{
try {
val controller = controllerFuture.get()
mediaControllerState.controller = controller
controllerFuture.addListener(
{
try {
val controller = controllerFuture.get(5, TimeUnit.SECONDS)
// checks if the player is still active before engaging further
if (mediaControllerState.isActive()) {
onReady(mediaControllerState)
} else {
removeController(mediaControllerState)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
}
},
executorService,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
Log.d("PlaybackService", "Controller Ready $id $videoUri")
// checks if the player is still active before engaging further
trySend(
MediaControllerState(
id = id,
controller = controller,
),
)
} catch (e: Exception) {
Log.e("Playback Client", "Failed to load Playback Client for $id $videoUri", e)
close(e)
}
},
executorService,
)
awaitClose {
Log.d("PlaybackService", "Releasing Controller $id $videoUri")
try {
MediaController.releaseFuture(controllerFuture)
} catch (e: Exception) {
Log.e("Playback Client", "Failed to release Playback Client for $id $videoUri ${e.message}")
}
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -32,6 +33,7 @@ class ScreenAuthAccount(
val account: Account,
)
@Stable
class AuthCoordinator(
client: INostrClient,
scope: CoroutineScope,
@@ -20,10 +20,12 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
@Stable
class NotifyRequestsCache {
val transientPaymentRequestDismissals: MutableStateFlow<Set<NotifyRequest>> = MutableStateFlow(emptySet())
val transientPaymentRequests: MutableStateFlow<Set<NotifyRequest>> = MutableStateFlow(emptySet())
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
@@ -38,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashF
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -78,7 +80,9 @@ class RelaySubscriptionsCoordinator(
val profile = UserProfileFilterAssembler(client)
val hashtags = HashtagFilterAssembler(client)
val geohashes = GeoHashFilterAssembler(client)
val relayFeed = RelayFeedFilterAssembler(client)
val followPacks = FollowPackFeedFilterAssembler(client)
val chess = ChessFilterAssembler(client)
// active when sending zaps via NWC
val nwc = NWCPaymentFilterAssembler(client)
@@ -101,6 +105,8 @@ class RelaySubscriptionsCoordinator(
profile,
hashtags,
geohashes,
relayFeed,
chess,
nwc,
)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -29,7 +30,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadat
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromRandomRelaysManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.paymentTargets.AccountPaymentTargetsEoseManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus
import kotlinx.coroutines.CoroutineScope
// This allows multiple screen to be listening to logged-in accounts.
@Stable
class AccountQueryState(
val account: Account,
val feedContentStates: AccountFeedContentStates,
@@ -47,6 +48,7 @@ class AccountQueryState(
/**
* This assembler loads everything eech account needs.
*/
@Stable
class AccountFilterAssembler(
client: INostrClient,
cache: LocalCache,
@@ -62,7 +64,6 @@ class AccountFilterAssembler(
AccountDraftsEoseManager(client, ::allKeys),
AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys),
AccountNotificationsEoseFromRandomRelaysManager(client, ::allKeys),
AccountPaymentTargetsEoseManager(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
@@ -152,8 +152,6 @@ class AccountFollowsLoaderSubAssembler(
if (users.isEmpty()) return null
println("AccountFollowNeeds ${users.size}")
val connectedRelays = client.connectedRelaysFlow().value
val perRelay = pickRelaysToLoadUsers(users, accounts, connectedRelays, failureTracker.cannotConnectRelays, hasTried)
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
@@ -67,6 +69,8 @@ val AccountInfoAndListsFromKeyKinds2 =
HashtagListEvent.KIND,
GeohashListEvent.KIND,
TrustProviderListEvent.KIND,
PaymentTargetsEvent.KIND,
FavoriteRelayListEvent.KIND,
)
val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND)
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
@@ -61,6 +62,7 @@ val BasicAccountInfoKinds2 =
HashtagListEvent.KIND,
GeohashListEvent.KIND,
TrustProviderListEvent.KIND,
FavoriteRelayListEvent.KIND,
)
fun filterBasicAccountInfoFromKeys(
@@ -46,6 +46,8 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
val SummaryKinds =
listOf(
@@ -64,6 +66,8 @@ val NotificationsPerKeyKinds =
EphemeralChatEvent.KIND,
BadgeAwardEvent.KIND,
PollNoteEvent.KIND,
PollEvent.KIND,
PollResponseEvent.KIND,
PublicMessageEvent.KIND,
)
@@ -1,83 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.paymentTargets
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class AccountPaymentTargetsEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
if (key.account.isWriteable()) {
relayFlow(key).value.flatMap {
listOf(
filterPaymentTargetsFromKey(it, user(key).pubkeyHex, since?.get(it)?.time),
).flatten()
}
} else {
emptyList()
}
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: AccountQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.account.scope.launch(Dispatchers.IO) {
relayFlow(key).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler
@@ -27,10 +28,12 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28Pu
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
// This allows multiple screen to be listening to tags, even the same tag
@Stable
class ChannelFinderQueryState(
val channel: Channel,
)
@Stable
class ChannelFinderFilterAssemblyGroup(
client: INostrClient,
) : ComposeSubscriptionManager<ChannelFinderQueryState>() {
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
@@ -28,11 +29,13 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers.
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
// This allows multiple screen to be listening to tags, even the same tag
@Stable
class EventFinderQueryState(
val note: Note,
val account: Account,
)
@Stable
class EventFinderFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<EventFinderQueryState>() {
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent
import com.vitorpamplona.quartz.utils.mapOfSet
@@ -49,7 +50,6 @@ val RepliesAndReactionsKinds =
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
OtsEvent.KIND,
TextNoteModificationEvent.KIND,
CommentEvent.KIND,
@@ -62,6 +62,8 @@ val RepliesAndReactionsKinds2 =
NIP90StatusEvent.KIND,
TorrentCommentEvent.KIND,
GitReplyEvent.KIND,
PollResponseEvent.KIND,
PollNoteEvent.KIND,
)
fun filterRepliesAndReactionsToNotes(
@@ -20,12 +20,14 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
// This allows multiple screen to be listening to tags, even the same tag
@Stable
class NWCPaymentQueryState(
val fromServiceHex: HexKey,
val toUserHex: HexKey,
@@ -33,6 +35,7 @@ class NWCPaymentQueryState(
val relay: NormalizedRelayUrl,
)
@Stable
class NWCPaymentFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<NWCPaymentQueryState>() {
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -32,11 +33,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
// This allows multiple screen to be listening to tags, even the same tag
@Stable
class UserFinderQueryState(
val user: User,
val account: Account,
)
@Stable
class UserFinderFilterAssembler(
client: INostrClient,
cache: LocalCache,
@@ -43,7 +43,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
@@ -355,6 +354,27 @@ fun observeUserIsFollowingGeohash(
return flow.collectAsStateWithLifecycle(geohash in accountViewModel.account.geohashList.flow.value)
}
@SuppressLint("StateFlowValueCalledInComposition")
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserIsFollowingRelay(
relayUrl: NormalizedRelayUrl,
accountViewModel: AccountViewModel,
): State<Boolean> {
val flow =
remember(accountViewModel) {
accountViewModel.account.favoriteRelayList.flowNoDefaults
.mapLatest { relays ->
relayUrl in relays
}.onStart {
emit(relayUrl in accountViewModel.account.favoriteRelayList.flowNoDefaults.value)
}.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
return flow.collectAsStateWithLifecycle(relayUrl in accountViewModel.account.favoriteRelayList.flowNoDefaults.value)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserIsFollowingChannel(
@@ -55,7 +55,7 @@ class UserOutboxFinderSubAssembler(
relay: NormalizedRelayUrl,
time: Long,
filters: List<Filter>? = null,
) = {
) {
filters?.forEach {
it.authors?.forEach {
cache.getUserIfExists(it)?.let { user ->
@@ -30,12 +30,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.mapOfSet
val UserMetadataForKeyKinds =
listOf(
MetadataEvent.KIND,
ExternalIdentitiesEvent.KIND,
StatusEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
@@ -49,7 +49,7 @@ class UserWatcherSubAssembler(
relay: NormalizedRelayUrl,
time: Long,
filters: List<Filter>? = null,
) = {
) {
filters?.forEach {
it.authors?.forEach {
cache.getUserIfExists(it)?.let { user ->
@@ -40,6 +40,7 @@ class SearchQueryState(
override fun flow(): Flow<String> = searchQuery
}
@Stable
class SearchFilterAssembler(
client: INostrClient,
scope: CoroutineScope,
@@ -47,6 +47,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
val SearchPostsByTextKinds1 =
@@ -83,6 +85,8 @@ val SearchPostsByTextKinds3 =
InteractiveStorySceneEvent.KIND,
FollowListEvent.KIND,
NipTextEvent.KIND,
PollEvent.KIND,
PollResponseEvent.KIND,
)
fun searchPostsByText(
@@ -110,5 +110,7 @@ class ByteArrayMediaDataSource(
}
@Throws(IOException::class)
override fun close() {}
override fun close() {
// No resources to release; data is an in-memory byte array
}
}
@@ -32,6 +32,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
@Stable
class MultiOrchestrator(
uris: List<SelectedMedia>,
) {
@@ -199,12 +199,16 @@ object VideoCompressionHelper {
),
listener =
object : CompressionListener {
override fun onStart(index: Int) {}
override fun onStart(index: Int) {
// No action needed on compression start
}
override fun onProgress(
index: Int,
percent: Float,
) {}
) {
// Progress tracking not needed for this use case
}
override fun onSuccess(
index: Int,
@@ -26,8 +26,6 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.model.Account
@@ -38,7 +36,6 @@ import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
@@ -68,13 +65,7 @@ class MainActivity : AppCompatActivity() {
setContent {
StringResSetup()
AmethystTheme {
val accountStateViewModel: AccountStateViewModel = viewModel()
LaunchedEffect(key1 = Unit) {
accountStateViewModel.loginWithDefaultAccountIfLoggedOff()
}
AccountScreen(accountStateViewModel)
AccountScreen(Amethyst.instance.sessionManager)
}
}
}
@@ -78,7 +78,6 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.BechLink
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
@@ -89,6 +88,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton
import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -193,7 +193,7 @@ fun EditPostView(
baseNote = it,
modifier = MaterialTheme.colorScheme.replyModifier,
isQuotedNote = true,
unPackReply = false,
unPackReply = ReplyRenderType.NONE,
makeItShort = true,
quotesLeft = 1,
accountViewModel = accountViewModel,
@@ -264,9 +264,7 @@ fun EditPostView(
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context)
if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server)
}
accountViewModel.account.settings.changeDefaultFileServer(server)
},
onDelete = postViewModel::deleteMediaToUpload,
onCancel = { postViewModel.multiOrchestrator = null },
@@ -44,6 +44,7 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.toasts.ThrowableToastMsg
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -52,25 +53,23 @@ import java.io.StringWriter
@Composable
fun InformationDialog(
title: String,
textContent: String?,
throwable: Throwable,
obj: ThrowableToastMsg,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onDismiss: () -> Unit,
) {
val str = textContent ?: throwable.localizedMessage ?: throwable.message ?: throwable.javaClass.simpleName
val str = obj.msg ?: obj.throwable.localizedMessage ?: obj.throwable.message ?: obj.throwable.javaClass.simpleName
val stack =
remember(throwable) {
remember(obj) {
val writer = StringWriter()
writer.append("\n")
throwable.printStackTrace(PrintWriter(writer))
obj.throwable.printStackTrace(PrintWriter(writer))
writer.toString()
}
InformationDialog(title = title, textContent = str, moreInfo = stack, buttonColors, onDismiss)
InformationDialog(title = stringRes(obj.titleResId), textContent = str, moreInfo = stack, buttonColors, onDismiss)
}
@Composable
@@ -54,7 +54,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
@@ -110,9 +109,7 @@ fun NewMediaView(
onPost = {
postViewModel.upload(context, onClose, accountViewModel.toastManager::toast)
postViewModel.selectedServer?.let {
if (it.type != ServerType.NIP95) {
account.settings.changeDefaultFileServer(it)
}
account.settings.changeDefaultFileServer(it)
}
},
)
@@ -148,19 +145,14 @@ fun ImageVideoPost(
postViewModel: NewMediaModel,
accountViewModel: AccountViewModel,
) {
val nip95description = stringRes(id = R.string.upload_server_relays_nip95)
val fileServers by accountViewModel.account.serverLists.liveServerList
val fileServers by accountViewModel.account.blossomServers.hostNameFlow
.collectAsState()
val fileServerOptions =
remember(fileServers) {
fileServers
.map {
if (it.type == ServerType.NIP95) {
TitleExplainer(it.name, nip95description)
} else {
TitleExplainer(it.name, it.baseUrl)
}
TitleExplainer(it.name, it.baseUrl)
}.toImmutableList()
}
@@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
@@ -197,7 +197,7 @@ class NewMessageTagger(
val restOfWord = key.substring(63)
// Converts to npub
val pubkey =
Nip19Parser.uriToRoute(Nip01.pubKeyCreate(keyB32.bechToBytes()).toNpub()) ?: return null
Nip19Parser.uriToRoute(Nip01Crypto.pubKeyCreate(keyB32.bechToBytes()).toNpub()) ?: return null
return DirtyKeyInfo(pubkey, restOfWord.ifEmpty { null })
} else if (key.startsWith("npub1", true)) {
@@ -1,110 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.CancellationException
@Composable
fun NewPollClosing(pollViewModel: ShortNotePostViewModel) {
var text by rememberSaveable { mutableStateOf("") }
pollViewModel.isValidClosedAt.value = true
if (text.isNotEmpty()) {
try {
val int = text.toLong()
if (int < 0) {
pollViewModel.isValidClosedAt.value = false
} else {
pollViewModel.closedAt = int
}
} catch (e: Exception) {
if (e is CancellationException) throw e
pollViewModel.isValidClosedAt.value = false
}
}
val colorInValid =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.error,
unfocusedBorderColor = Color.Red,
)
val colorValid =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText,
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
colors = if (pollViewModel.isValidClosedAt.value) colorValid else colorInValid,
label = {
Text(
text = stringRes(R.string.poll_closing_time),
color = MaterialTheme.colorScheme.placeholderText,
)
},
placeholder = {
Text(
text = stringRes(R.string.poll_closing_time_days),
color = MaterialTheme.colorScheme.placeholderText,
)
},
)
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview
@Composable
fun NewPollClosingPreview() {
NewPollClosing(ShortNotePostViewModel())
}

Some files were not shown because too many files have changed in this diff Show More