mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Generated
-4
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
+30
-6
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
) {
|
||||
|
||||
+29
@@ -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)
|
||||
+134
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
-81
@@ -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)
|
||||
}
|
||||
}
|
||||
+26
@@ -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()
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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))
|
||||
|
||||
-73
@@ -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,
|
||||
)
|
||||
}
|
||||
+65
-19
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-16
@@ -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())
|
||||
}
|
||||
}
|
||||
+63
@@ -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 })
|
||||
}
|
||||
}
|
||||
+27
@@ -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
|
||||
+28
@@ -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
|
||||
+2
-1
@@ -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))
|
||||
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+3
-4
@@ -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()
|
||||
|
||||
+30
-200
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+14
-16
@@ -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
|
||||
|
||||
+95
-67
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-12
@@ -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,
|
||||
)
|
||||
|
||||
+6
-6
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+97
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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)
|
||||
+132
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+19
-2
@@ -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),
|
||||
)
|
||||
|
||||
+195
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
-3
@@ -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),
|
||||
)
|
||||
|
||||
+130
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+84
@@ -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,
|
||||
)
|
||||
}
|
||||
+78
@@ -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)
|
||||
}
|
||||
}
|
||||
+77
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
-82
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+185
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-30
@@ -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(
|
||||
|
||||
+23
-7
@@ -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) }
|
||||
|
||||
+96
@@ -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))
|
||||
}
|
||||
}
|
||||
+16
-14
@@ -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(
|
||||
|
||||
+13
-20
@@ -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
-13
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+81
@@ -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
|
||||
}
|
||||
+21
-23
@@ -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()
|
||||
|
||||
+20
-25
@@ -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(),
|
||||
)
|
||||
|
||||
+72
-76
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+95
@@ -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)
|
||||
}
|
||||
+13
-9
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-9
@@ -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++
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
+53
-67
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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())
|
||||
|
||||
+6
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+3
-2
@@ -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()
|
||||
|
||||
-2
@@ -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)
|
||||
|
||||
+4
@@ -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)
|
||||
|
||||
+2
@@ -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(
|
||||
|
||||
+4
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
-83
@@ -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() }
|
||||
}
|
||||
}
|
||||
+3
@@ -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>() {
|
||||
|
||||
+3
@@ -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>() {
|
||||
|
||||
+3
-1
@@ -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(
|
||||
|
||||
+3
@@ -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>() {
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+21
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ class UserOutboxFinderSubAssembler(
|
||||
relay: NormalizedRelayUrl,
|
||||
time: Long,
|
||||
filters: List<Filter>? = null,
|
||||
) = {
|
||||
) {
|
||||
filters?.forEach {
|
||||
it.authors?.forEach {
|
||||
cache.getUserIfExists(it)?.let { user ->
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ class UserWatcherSubAssembler(
|
||||
relay: NormalizedRelayUrl,
|
||||
time: Long,
|
||||
filters: List<Filter>? = null,
|
||||
) = {
|
||||
) {
|
||||
filters?.forEach {
|
||||
it.authors?.forEach {
|
||||
cache.getUserIfExists(it)?.let { user ->
|
||||
|
||||
+1
@@ -40,6 +40,7 @@ class SearchQueryState(
|
||||
override fun flow(): Flow<String> = searchQuery
|
||||
}
|
||||
|
||||
@Stable
|
||||
class SearchFilterAssembler(
|
||||
client: INostrClient,
|
||||
scope: CoroutineScope,
|
||||
|
||||
+4
@@ -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>,
|
||||
) {
|
||||
|
||||
+6
-2
@@ -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
Reference in New Issue
Block a user