Merge remote-tracking branch 'upstream/main' into feat/desktop-privacy-lock

# Conflicts:
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
This commit is contained in:
nrobi144
2026-07-02 07:20:44 +03:00
270 changed files with 15571 additions and 422 deletions
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
# PreToolUse gate: make sure Kotlin is spotless-clean BEFORE it leaves the box.
#
# Fires on `git push` (Bash tool) and on the create_pull_request MCP tool. Runs
# `spotlessApply`; if that reformats any tracked .kt/.kts file, the push/PR is
# blocked (exit 2) so the agent commits the formatting fix first. This turns
# CI's `spotlessCheck` failure into an in-session block — no red PR, no round
# trip. `spotlessApply` runs the same formatters CI's `spotlessCheck` verifies,
# so a clean apply means a green check.
set -uo pipefail
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
# --- Parse the tool call off stdin; decide whether this call is a boundary. ---
payload="$(cat)"
should_gate="$(
printf '%s' "$payload" | python3 -c '
import json, shlex, sys
try:
data = json.load(sys.stdin)
except Exception:
print("no"); sys.exit(0)
tool = data.get("tool_name", "")
if tool.endswith("create_pull_request"):
print("yes"); sys.exit(0)
if tool != "Bash":
print("no"); sys.exit(0)
cmd = (data.get("tool_input") or {}).get("command", "")
# Tokenize like a shell so `push` inside a quoted commit message or heredoc
# stays one token and is NOT mistaken for the push subcommand.
try:
tokens = shlex.split(cmd, comments=True)
except ValueError:
tokens = cmd.split()
GLOBAL_WITH_ARG = {"-c", "-C", "--namespace", "--git-dir", "--work-tree", "--exec-path"}
for i, t in enumerate(tokens):
if t != "git" and not t.endswith("/git"):
continue
j = i + 1
while j < len(tokens): # skip git global options to reach the subcommand
tok = tokens[j]
if tok in GLOBAL_WITH_ARG:
j += 2; continue
if tok.startswith("-"):
j += 1; continue
break
if j < len(tokens) and tokens[j] == "push":
print("yes"); sys.exit(0)
print("no")
' 2>/dev/null
)"
[ "$should_gate" = "yes" ] || exit 0
# Nothing to format if no Kotlin is tracked/changed at all — cheap early out.
if ! git ls-files --error-unmatch '*.kt' '*.kts' >/dev/null 2>&1; then
exit 0
fi
# Snapshot Kotlin state (vs HEAD, so staged + unstaged both count) before/after
# formatting; any delta means the committed tree wasn't spotless.
before="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)"
log="$(mktemp /tmp/spotless-gate.XXXXXX.log)"
if ! ./gradlew spotlessApply >"$log" 2>&1; then
# Distinguish a formatting failure (block) from Gradle being unable to RUN —
# e.g. deps can't resolve in a restricted sandbox. An infra failure must not
# strand the agent; warn and let CI's spotlessCheck be the backstop.
if grep -qiE "could not resolve|could not (get|download)|handshake|connect timed out|no address|unable to (find|resolve) host|read timed out" "$log"; then
echo "WARN: could not run spotlessApply (Gradle infra/network failure), skipping the formatting gate." >&2
echo " CI's spotlessCheck still enforces formatting on the PR." >&2
rm -f "$log"
exit 0
fi
echo "BLOCKED: spotlessApply failed — fix the build/formatting error before pushing." >&2
echo "----- gradle output (tail) -----" >&2
tail -n 40 "$log" >&2
rm -f "$log"
exit 2
fi
rm -f "$log"
after="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)"
if [ "$before" != "$after" ]; then
echo "BLOCKED: spotlessApply reformatted Kotlin files that were about to be pushed." >&2
echo "The changes below are now in your working tree. Commit them, then retry:" >&2
echo >&2
git diff --name-only HEAD -- '*.kt' '*.kts' >&2
echo >&2
echo " git add -A && git commit -m 'style: apply spotless' && <retry the push>" >&2
echo "(CI runs 'spotlessCheck'; pushing now would fail the lint job.)" >&2
exit 2
fi
exit 0
+12
View File
@@ -1,5 +1,17 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|mcp__github__create_pull_request",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-push-spotless.sh",
"timeout": 180
}
]
}
],
"SessionStart": [
{
"hooks": [
+74 -1
View File
@@ -1,6 +1,6 @@
---
name: nostr-expert
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent), (8) Resolving user input (hex, npub, nprofile, or NIP-05 `name@domain` internet identifiers) to a pubkey. Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
---
# Nostr Protocol Expert (Quartz Implementation)
@@ -15,6 +15,7 @@ Practical patterns for working with Nostr in Quartz, AmethystMultiplatform's KMP
- Finding NIP implementations in quartz/ codebase
- Nostr cryptography (secp256k1 signing, NIP-44 encryption)
- Bech32 encoding/decoding (npub, nsec, note formats)
- Resolving user input (hex / npub / nprofile / NIP-05 `name@domain`) to a pubkey
- Event validation and verification
**For NIP specifications** → Use `nostr-protocol` agent
@@ -345,6 +346,55 @@ object Nip04 {
**Note**: Use NIP-44 (`Nip44`) for new implementations. NIP-04 has security issues.
## Hex Encoding (HexKey ↔ ByteArray)
Pubkeys, event ids and signatures are lower-case hex. Quartz uses the `HexKey`
typealias (`= String`) plus extensions in `nip01Core/core/HexKey.kt`, backed by
the `Hex` object in `utils/Hex.kt`. **Use these — never hand-roll a byte loop or
import a third-party hex codec.**
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.Hex
val hex: HexKey = bytes.toHexKey() // ByteArray -> lower-case hex
val back: ByteArray = hex.hexToByteArray() // hex -> ByteArray (throws on odd length)
val safe: ByteArray? = input.hexToByteArrayOrNull() // null on invalid hex
Hex.isHex(input) // valid hex, any length
Hex.isHex64(input) // ~30% faster fast-path for a 32-byte key/id
hex.isValid() // 64 chars + valid hex (pubkey / event-id shape)
Hex.isEqual(hex, bytes) // compare hex to bytes without decoding
```
Constants `PUBKEY_LENGTH` / `EVENT_ID_LENGTH` (both 64) live in `nip01Core.core`.
## Core Utilities (time, random, event id)
Reuse these instead of hand-rolling — each avoids a common mistake:
```kotlin
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.sha256.sha256
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
TimeUtils.now() // Unix SECONDS for created_at — not currentTimeMillis()/1000
TimeUtils.oneHourAgo() // relative filter bounds (…Ago / …FromNow); all in seconds
RandomInstance.bytes(32) // secure random (SecureRandom) — for nonces/keys, not kotlin.random.Random
RandomInstance.randomChars() // 16-char subscription id
sha256(bytes) // raw hash primitive
EventHasher.hashId(pubKey, createdAt, kind, tags, content) // canonical event id
EventHasher.hashIdCheck(id, pubKey, createdAt, kind, tags, content) // verify untrusted events
```
`EventHasher` serializes `[0, pubkey, created_at, kind, tags, content]` in the
exact form NIP-01 requires — prefer it over calling `sha256` on your own JSON.
## Bech32 Encoding (NIP-19)
Encoding uses extension functions on `ByteArray` (`nip19Bech32/ByteArrayExt.kt`);
@@ -375,6 +425,25 @@ when (val entity = Nip19Parser.uriToRoute(input)?.entity) {
}
```
## Resolving User Input to a Pubkey (NIP-05 + NIP-19)
**Before writing any `if (isHex) … else if (npub) … else if ("@" in s) fetchWellKnown()` logic, stop — it already exists.** `resolveUserHexOrNull` in `quartz/nip05DnsIdentifiers/` accepts every identifier form a user might type and returns a 64-hex pubkey.
```kotlin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull
// hex | npub1… | nprofile1… | nsec1… | name@domain.tld → HexKey? (null if unrecognized/lookup fails)
val pubkey = resolveUserHexOrNull(userInput, nip05Client)
```
- Tries the **synchronous** hex/bech32 path first (`decodePublicKeyAsHexOrNull`) — only NIP-05-shaped input hits the network.
- `suspend`; re-throws only `CancellationException`. Pass `nip05Client = null` for offline contexts.
- Build the client with `Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> okHttp })` (see `cli/Context.kt`). The OkHttp fetcher already runs on IO and disables redirects per the NIP-05 spec — don't re-implement the `.well-known/nostr.json` fetch or JSON parse.
- Need only hex/bech32 (no network)? Use `decodePublicKeyAsHexOrNull(input)` directly.
- Need to *verify* a claimed identifier maps back to a pubkey? `nip05Client.verify(Nip05Id.parse(id)!!, pubkey)`.
See `references/nip05-identifiers.md` for the full API surface (`Nip05Id`, `Nip05Client`, `Nip05Parser`, `KeyInfoSet`, Namecoin `.bit`) and the hand-rolled anti-pattern to avoid.
## Event Validation
```kotlin
@@ -503,6 +572,7 @@ Or see `references/nip-catalog.md` for complete catalog.
- **references/event-hierarchy.md** - Event class hierarchy, kind classifications, common types
- **references/tag-patterns.md** - Tag structure, TagArrayBuilder DSL, common tag types, parsing patterns
- **references/nip19-bech32.md** - `Nip19Parser`, `Bech32Util`, `TlvBuilder`, entity types (NPub, NSec, NEvent, NAddress, NProfile, NRelay, NEmbed)
- **references/nip05-identifiers.md** - Resolving any identifier (hex/npub/nprofile/nsec/`name@domain`) to a pubkey via `resolveUserHexOrNull`; `Nip05Client`, `Nip05Id`, `Nip05Parser`, Namecoin `.bit` — and the hand-rolled anti-pattern to avoid
- **references/event-factory.md** - `EventFactory` dispatch pattern and how to register a new kind
- **references/crypto-and-encryption.md** - Event signing/verification, secp256k1 abstraction, NIP-44 encryption, `SharedKeyCache`
- **references/large-cache.md** - `LargeCache<K,V>` expect/actual + `ICacheOperations` functional API
@@ -518,6 +588,9 @@ Or see `references/nip-catalog.md` for complete catalog.
| Verify signature | `event.verify()` | nip01Core/core/ |
| Encrypt (NIP-44) | `Nip44v2.encrypt(...)` | nip44Encryption/ |
| Bech32 encode | `Nip19.npubEncode(...)` | nip19Bech32/ |
| Resolve input → pubkey | `resolveUserHexOrNull(input, nip05Client)` | nip05DnsIdentifiers/ |
| Decode bech32 → pubkey (no net) | `decodePublicKeyAsHexOrNull(input)` | nip19Bech32/ |
| Verify NIP-05 identifier | `nip05Client.verify(Nip05Id.parse(id)!!, hex)` | nip05DnsIdentifiers/ |
| Find NIP | `scripts/nip-lookup.sh <number>` | - |
## Common Event Kinds
@@ -13,7 +13,7 @@ under `experimental/`**. The categorized list below may lag behind —
| 02 | `nip02FollowList/` | ContactListEvent.kt | Follow/contact lists (kind 3) |
| 03 | `nip03Timestamp/` | OpenTimestampsAttestation.kt | Timestamps |
| 04 | `nip04Dm/` | EncryptedDmEvent.kt | Legacy encrypted DMs (deprecated for NIP-17) |
| 05 | `nip05DnsIdentifiers/` | Nip05Verifier.kt | DNS-based verification |
| 05 | `nip05DnsIdentifiers/` | UserHexResolver.kt, Nip05Client.kt | Internet identifiers; `resolveUserHexOrNull` resolves hex/npub/nprofile/`name@domain` → pubkey (see references/nip05-identifiers.md) |
| 06 | `nip06KeyDerivation/` | Mnemonic-related | BIP-39 key derivation |
| 09 | `nip09Deletions/` | DeletionEvent.kt | Event deletion requests (kind 5) |
| 11 | `nip11RelayInfo/` | RelayInformation.kt | Relay metadata |
@@ -0,0 +1,98 @@
# NIP-05: Identifiers → Pubkey Resolution
How Quartz turns anything a human might type — a raw hex pubkey, an `npub`/`nprofile`/`nsec`, or a NIP-05 internet identifier (`alice@domain.tld`) — into a 64-hex Nostr pubkey. **Everything below already exists in `quartz/nip05DnsIdentifiers/`. Do not hand-roll it.**
## TL;DR — the one function you almost always want
```kotlin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull
// hex | npub1… | nprofile1… | nsec1… | name@domain.tld → 64-hex pubkey (or null)
val pubkey: HexKey? = resolveUserHexOrNull(userInput, nip05Client)
```
`resolveUserHexOrNull(input, nip05Client)` (in `UserHexResolver.kt`) is the canonical "accept any identifier form" resolver. It:
- trims input, tries the **synchronous** bech32/hex path first (`decodePublicKeyAsHexOrNull`), so hex/`npub`/`nprofile`/`nsec` never touch the network;
- only issues an HTTPS fetch for genuinely NIP-05-shaped input (`name@domain.tld`), gated by a cheap `looksLikeNip05()` precheck;
- returns `null` on anything unrecognizable or on a failed NIP-05 lookup (network error / no match);
- re-throws **only** `CancellationException`, so it's safe inside structured concurrency.
Pass `nip05Client = null` in pure-offline contexts — NIP-05-shaped inputs then fall through to `null` and no HTTP is attempted.
## ❌ Do not write this (the hand-rolled anti-pattern)
```kotlin
// DON'T. This re-implements resolveUserHexOrNull badly:
// - no nsec support
// - no input validation (accepts IP-literal / malformed domains → spurious fetches)
// - hand-parses JSON instead of using Nip05Parser
// - bespoke httpGet ignores the "MUST NOT follow redirects" rule
// - swallows CancellationException, breaking structured concurrency
fun resolveObserver(input: String): String? {
if (Hex.isHex64(input)) return input.lowercase()
if (input.startsWith("npub1") || input.startsWith("nprofile1")) { /* … */ }
if ("@" in input) return resolveNip05(input) // bespoke well-known fetch
return null
}
```
## ✅ Do this instead
```kotlin
// CLI already exposes it — commands call Context.requireUserHex(input):
val pubHex = resolveUserHexOrNull(input, nip05Client)
?: return Output.error("bad_args", "expected npub, nprofile, 64-hex, or name@domain.tld")
```
## Building an `Nip05Client`
`resolveUserHexOrNull` takes an `INip05Client`. On JVM/Android, wire the OkHttp fetcher (mirror what `cli/Context.kt` does):
```kotlin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
val nip05Client = Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> okHttpClient })
```
`OkHttpNip05Fetcher` already runs on `Dispatchers.IO` and disables redirects per the NIP-05 spec ("Fetchers MUST ignore any HTTP redirects"). Don't re-implement the fetch.
For tests / offline code, `EmptyNip05Client` is a no-op stub.
## The pieces (all in `quartz/…/nip05DnsIdentifiers/`)
| Type | File | Purpose |
|------|------|---------|
| `resolveUserHexOrNull(input, client?)` | `UserHexResolver.kt` | **Start here.** Any identifier form → 64-hex pubkey, or null. `suspend`. |
| `Nip05Id` | `Nip05Id.kt` | Parsed `name@domain`. `Nip05Id.parse(str)` validates (RFC 5321 local-part + hostname rules, rejects IP literals) and lowercases. `toUserUrl()` / `toDomainUrl()` build the `.well-known/nostr.json` URLs. `toDisplayValue()` collapses the `_` wildcard to just the domain. |
| `INip05Client` / `Nip05Client` | `INip05Client.kt`, `Nip05Client.kt` | Async resolver. `get(id): Nip05KeyInfo?` (pubkey + relays), `verify(id, hex): Boolean`, `load(id): KeyInfoSet?`, `list(domain): KeyInfoSet`, `loadClinkOffer(id): String?`. Auto-routes `.bit` domains to Namecoin. `EmptyNip05Client` = offline no-op. |
| `Nip05Fetcher` / `OkHttpNip05Fetcher` | `Nip05Fetcher.kt`, `OkHttpNip05Fetcher.kt` (jvmAndroid) | Transport SAM. OkHttp actual disables redirects + runs on IO. |
| `Nip05Parser` | `Nip05Parser.kt` | JSON `.well-known/nostr.json` codec: `parseHexKey`, `parseHexKeyAndRelays`, `parse` → `KeyInfoSet`, `parseClinkOffer`. |
| `Nip05KeyInfo` / `KeyInfoSet` | `Nip05KeyInfo.kt`, `KeyInfoSet.kt` | `Nip05KeyInfo(pubkey, relays)`; `KeyInfoSet(names: Map, relays: Map)` = the full domain listing. |
| `NamecoinNameResolver` | `namecoin/NamecoinNameResolver.kt` | `.bit` / `d/…` / `id/…` blockchain identifiers. `isNamecoinIdentifier(str)`, `resolve(str)`. Invoked automatically by `Nip05Client` — you rarely call it directly. |
## When you only need the pure (synchronous, no-network) part
If the input can only be hex/bech32 (no NIP-05), skip the client entirely:
```kotlin
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
// hex | npub1… | nprofile1… | nsec1… → 64-hex pubkey (or null). No suspend, no network.
val pubkey: HexKey? = decodePublicKeyAsHexOrNull(input)
```
See `references/nip19-bech32.md` for the full bech32 entity story. `resolveUserHexOrNull` is just this function plus the NIP-05 HTTP fallback.
## Verifying a claimed identifier
To confirm a profile's advertised `nip05` actually points back to its pubkey (NIP-05 verification), use `verify`, not `get`:
```kotlin
val ok: Boolean = nip05Client.verify(Nip05Id.parse("alice@domain.tld")!!, profilePubkeyHex)
```
## Tests
`quartz/src/commonTest/…/nip05DnsIdentifiers/Nip05Test.kt` covers parsing, URL construction, case-normalization, CLINK offers, and the validation rejects (IP literals, malformed domains).
+226 -3
View File
@@ -1,6 +1,6 @@
---
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.
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, (9) running a relay on Quartz and serving/building its NIP-11 relay information document (application/nostr+json).
---
# Quartz Integration Guide
@@ -134,13 +134,14 @@ val privKeyHex: String? = keyPair.privKey?.toHexKey()
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
// ByteArray → hex
val hex = byteArray.toHexKey()
// hex → ByteArray
val bytes = HexKey.decodeHex(hex)
val bytes = hex.hexToByteArray()
// Bech32 import (npub, nsec)
val parsed = Nip19Parser.uriToRoute("npub1abc...")
@@ -148,6 +149,126 @@ val parsed = Nip19Parser.uriToRoute("npub1abc...")
val parsed = Nip19Parser.uriToRoute("nsec1abc...")
```
> Hex ↔ ByteArray is a first-class utility in Quartz — see **§3.1 Hex utilities** below.
---
### 3.1 Hex utilities (HexKey ↔ ByteArray)
Nostr keys, event ids and signatures travel as lower-case hex strings. Quartz
models this with the `HexKey` typealias (just a `String`) plus extension
functions — **do not** write your own byte loop or pull in a third-party codec.
**Packages:** `com.vitorpamplona.quartz.nip01Core.core` (the extensions) and
`com.vitorpamplona.quartz.utils` (the underlying `Hex` object).
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.HexKey // typealias = String
import com.vitorpamplona.quartz.nip01Core.core.toHexKey // ByteArray → hex
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray // hex → ByteArray
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.Hex
// Encode / decode
val hex: HexKey = pubKeyBytes.toHexKey() // lower-case, 2 chars per byte
val bytes: ByteArray = hex.hexToByteArray() // throws on odd length
// Untrusted input → decode safely
val maybe: ByteArray? = userInput.hexToByteArrayOrNull() // null if not valid hex
// Validate without decoding (no allocation)
Hex.isHex(userInput) // even-length, all hex digits (any length)
Hex.isHex64(userInput) // fast path for a 32-byte key/id (checks first 64 chars)
hex.isValid() // 64 chars AND valid hex (pubkey / event-id shape)
// Compare a hex string to raw bytes without decoding
Hex.isEqual(incomingHexId, myIdBytes)
```
| Need | Call | Notes |
|------|------|-------|
| ByteArray → hex | `bytes.toHexKey()` | lower-case output |
| hex → ByteArray (strict) | `hex.hexToByteArray()` | throws on odd length |
| hex → ByteArray (safe) | `hex.hexToByteArrayOrNull()` | `null` on invalid hex |
| is this valid hex? | `Hex.isHex(s)` / `Hex.isHex64(s)` | `isHex64` ~30% faster for keys/ids |
| is this a pubkey/id shape? | `hex.isValid()` | 64 chars + valid hex |
| hex == bytes? | `Hex.isEqual(hex, bytes)` | no decode allocation |
Constants `PUBKEY_LENGTH` and `EVENT_ID_LENGTH` (both `64`) live in the same
`nip01Core.core` package.
---
### 3.2 Everyday utilities (time, random, hashing, bech32, base64)
These small helpers exist so you don't reinvent them — and several have a
footgun the built-in avoids. **Prefer them over stdlib/hand-rolled equivalents.**
**Time — `TimeUtils` (`com.vitorpamplona.quartz.utils`).** Everything is in Unix
**seconds** (what `created_at` and filter `since`/`until` use), *not* millis.
```kotlin
import com.vitorpamplona.quartz.utils.TimeUtils
val createdAt = TimeUtils.now() // seconds — for created_at. NOT currentTimeMillis()/1000
val since = TimeUtils.oneDayAgo() // relative filter bounds: oneHourAgo(), fiveMinutesAgo()…
val fresh = TimeUtils.withinTenMinutes(event.createdAt) // NIP-42/NIP-98 freshness
// TimeUtils.nowMillis() is the only millisecond helper — non-protocol use only.
```
**Secure random — `RandomInstance` (`utils`).** Backed by `SecureRandom`; use it
for anything security-sensitive instead of `kotlin.random.Random`.
```kotlin
import com.vitorpamplona.quartz.utils.RandomInstance
val nonce = RandomInstance.bytes(32) // nonces, salts, keys
val subId = RandomInstance.randomChars() // 16-char [a-zA-Z0-9] subscription id
```
**Hashing — `sha256(...)` + `EventHasher`.** `sha256` is the raw primitive; to
compute/verify an **event id** use `EventHasher`, which canonically serializes
`[0, pubkey, created_at, kind, tags, content]` before hashing (getting this wrong
is what makes relays reject an event). Typed builders already do this for you.
```kotlin
import com.vitorpamplona.quartz.utils.sha256.sha256
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
val digest = sha256(bytes) // raw 32-byte hash
val id = EventHasher.hashId(pubKey, createdAt, kind, tags, content)
val valid = EventHasher.hashIdCheck(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content)
```
**Bech32.** For `npub`/`nsec`/`note`/… prefer the NIP-19 layer (`ByteArray.toNpub()`,
`Nip19Parser.uriToRoute(...)` — see §10). Drop to the low-level
`Bech32` object (`nip19Bech32.bech32`) only for a custom prefix:
```kotlin
import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
val addr = Bech32.encodeBytes("npub", pubKeyBytes, Bech32.Encoding.Bech32)
val bytes = "npub1...".bechToBytes("npub") // decode + assert the prefix
```
**Base64.** Quartz has no wrapper — use the Kotlin stdlib `kotlin.io.encoding.Base64`
directly, and match the variant the spec wants: NIP-44/NIP-04 payloads use
`Base64.Default` (standard, padded); url-safe contexts use `Base64.UrlSafe`
(configure padding via `.withPadding(...)`).
| Need | Call |
|------|------|
| Now (event `created_at`) | `TimeUtils.now()` (seconds) |
| Relative filter bound | `TimeUtils.oneDayAgo()` / `oneHourAgo()` / … |
| Secure random bytes | `RandomInstance.bytes(n)` |
| Subscription id | `RandomInstance.randomChars()` |
| Raw hash | `sha256(bytes)` |
| Event id / verify | `EventHasher.hashId(...)` / `hashIdCheck(...)` |
| Bech32 custom prefix | `Bech32.encodeBytes(hrp, bytes, enc)` / `s.bechToBytes(hrp)` |
| Base64 | `kotlin.io.encoding.Base64` (`.Default` / `.UrlSafe`) |
---
## 4. Signing Events
@@ -634,7 +755,100 @@ val results = store.query<Event>(Filter(search = "bitcoin"))
---
## 15. Quick Reference
## 15. NIP-11 Relay Information Document
If you're standing up a relay on Quartz's relay-server code, serve your NIP-11
document with the **type-safe builder** — don't hand-write the JSON string.
**Package:** `com.vitorpamplona.quartz.nip11RelayInfo`
```kotlin
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip11RelayInfo.relayInformation
val info =
relayInformation {
name = "sot"
description = "NIP-50 profile search ranked by Nostr web-of-trust"
software = "https://github.com/vitorpamplona/sot"
version = "0.1"
supports(1, 11, 42, 50) // ints → spec-compliant [1,11,42,50] in the JSON
}
val json = info.toJson() // null/empty fields are omitted
```
Serve it at the relay root, branching on the `Accept` header (Ktor example):
```kotlin
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import io.ktor.http.ContentType
get("/") {
val accept = call.request.headers[HttpHeaders.Accept].orEmpty()
if (accept.contains(Nip11RelayInformation.CONTENT_TYPE)) { // "application/nostr+json"
call.respondText(json, ContentType.parse(Nip11RelayInformation.CONTENT_TYPE))
} else {
call.respondText("Open a WebSocket (NIP-01) or send Accept: ${Nip11RelayInformation.CONTENT_TYPE}")
}
}
```
### Nested objects, lists, and enforced limits
```kotlin
val info =
relayInformation {
name = "Paid Relay"
supports(1, 11, 42)
supportsExtensions("nip50-search") // supported_nip_extensions
countries("US", "CA") // relay_countries; also languages(...), tags(...)
nip50Features("profile_search") // the `nip50` field
// limitation { } — camelCase maps to NIP-11 snake_case fields
limitation {
maxSubscriptions = 20
maxFilters = 10
authRequired = true
}
// fees { } — each helper is repeatable
fees {
admission(amount = 1000, unit = "msats")
publication(amount = 100, unit = "msats", kinds = listOf(1, 30023))
}
// retention(...) — call once per policy entry
retention(kinds = listOf(0, 3), count = 1)
}
```
**Keep advertised limits in sync with enforced ones.** If you build a
`RelayLimits` for the server's policy chain, hand the *same* object to the
builder so what you publish can never drift from what you enforce:
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits
val limits = RelayLimits(maxSubscriptions = 20, maxFilters = 10, maxLimit = 500, authRequired = true)
val info =
relayInformation {
name = "My Relay"
supports(1, 11, 42, 45)
limitation(limits) // == limits.toNip11Limitation()
}
```
To load an operator-supplied doc from disk or a string instead of building it,
use `Nip11RelayInformation.fromJson(json)`.
> `geode` (Quartz's standalone relay) builds its default document exactly this
> way — see `geode/.../RelayInfo.kt`.
---
## 16. Quick Reference
| Task | API | Package |
|------|-----|---------|
@@ -644,6 +858,13 @@ val results = store.query<Event>(Filter(search = "bitcoin"))
| Sign event | `signer.sign(template)` | `nip01Core.signers` |
| Serialize | `event.toJson()` | `nip01Core.core` |
| Parse | `Event.fromJson(json)` | `nip01Core.core` |
| ByteArray → hex | `bytes.toHexKey()` | `nip01Core.core` |
| hex → ByteArray | `hex.hexToByteArray()` / `hex.hexToByteArrayOrNull()` | `nip01Core.core` |
| Validate hex | `Hex.isHex(s)` / `Hex.isHex64(s)` / `hex.isValid()` | `utils`, `nip01Core.core` |
| Now (seconds) | `TimeUtils.now()` | `utils` |
| Relative time | `TimeUtils.oneDayAgo()` / `oneHourAgo()` | `utils` |
| Secure random | `RandomInstance.bytes(n)` / `randomChars()` | `utils` |
| Hash / event id | `sha256(bytes)` / `EventHasher.hashId(...)` | `utils.sha256`, `nip01Core.crypto` |
| 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` |
@@ -651,6 +872,8 @@ val results = store.query<Event>(Filter(search = "bitcoin"))
| NIP-44 encrypt | `signer.nip44Encrypt(text, recipientPubKey)` | `nip01Core.signers` |
| Bech32 decode | `Nip19Parser.uriToRoute("npub1...")` | `nip19Bech32` |
| Bech32 encode | `Nip19Bech32.createNPub(pubKeyHex)` | `nip19Bech32` |
| Build NIP-11 doc | `relayInformation { name = ...; supports(1, 11) }` | `nip11RelayInfo` |
| Serialize NIP-11 doc | `info.toJson()` (media type `Nip11RelayInformation.CONTENT_TYPE`) | `nip11RelayInfo` |
## Common Event Kinds
+4
View File
@@ -0,0 +1,4 @@
# Gzip-compressed test corpora (e.g. nostr_vitor_startup_data.json.gz): treat as
# binary so git never applies CRLF/text normalization or textual diff/merge, which
# would corrupt the compressed stream (important on Windows checkouts).
*.gz binary
+4 -4
View File
@@ -19,7 +19,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -66,7 +66,7 @@ jobs:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -122,7 +122,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -181,7 +181,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
+7 -7
View File
@@ -50,7 +50,7 @@ jobs:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -200,7 +200,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -249,7 +249,7 @@ jobs:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -451,7 +451,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -479,7 +479,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -488,7 +488,7 @@ jobs:
java-version: 21
- name: Cache gradle
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
@@ -626,7 +626,7 @@ jobs:
fi
- name: Upload Android assets to GH Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: dist/*
tag_name: ${{ github.ref_name }}
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
# Need tags so scripts/translators.sh can resolve the last v* release tag
# for the "since last tag" window.
@@ -54,7 +54,7 @@ jobs:
- name: Open or update the combined Crowdin PR
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
# linked into any shipped artifact). It no-ops when there is no diff.
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
base: main
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -55,7 +55,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -289,6 +289,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
@@ -3752,7 +3754,12 @@ object LocalCache : ILocalCache, ICacheProvider {
}
is PodcastMetadataEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
// Drop the known "Mock Podcast" spam flood instead of caching thousands of them.
if (event.isMockSpam()) {
false
} else {
consumeBaseReplaceable(event, relay, wasVerified)
}
}
is AuthoredPodcastsEvent -> {
@@ -3763,6 +3770,14 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
is Podcasting20EpisodeEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is Podcasting20TrailerEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is LnZapEvent -> {
consume(event, relay, wasVerified)
}
@@ -205,7 +205,7 @@ class UiSharedPreferences(
preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name
preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name
preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name
preferences[UI_BOTTOM_BAR_ITEMS] = JsonMapper.toJson(sharedSettings.bottomBarItems)
preferences[UI_BOTTOM_BAR_ITEMS] = encodeBottomBarItems(sharedSettings.bottomBarItems)
preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab
preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab
preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab
@@ -226,7 +226,19 @@ class UiSharedPreferences(
}
}
private fun decodeBottomBarItems(raw: String): List<BottomBarEntry>? {
/**
* Persists "follow the defaults" as a blank sentinel instead of the concrete default list.
*
* A user who resets the bottom bar (or who never customized it) should track whatever
* [DefaultBottomBarEntries] is in the *installed* app version. Storing the concrete list would
* pin them to today's default, so a future version that changes the default would never reach
* them. Storing a blank value instead makes [decodeBottomBarItems] resolve it back to the
* current [DefaultBottomBarEntries] on every load — i.e. the user is automatically migrated to
* the new default. Any genuinely customized bar is still stored as JSON.
*/
internal fun encodeBottomBarItems(items: List<BottomBarEntry>): String = if (items == DefaultBottomBarEntries) "" else JsonMapper.toJson(items)
internal fun decodeBottomBarItems(raw: String): List<BottomBarEntry>? {
if (raw.isBlank()) return DefaultBottomBarEntries
// Current format: a JSON list of BottomBarEntry (built-ins + favorites).
runCatching { return JsonMapper.fromJson<List<BottomBarEntry>>(raw) }
@@ -0,0 +1,289 @@
/*
* 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
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm
import com.vitorpamplona.quartz.podcasts.PodcastBoostagram
import com.vitorpamplona.quartz.podcasts.PodcastValue
import com.vitorpamplona.quartz.podcasts.PodcastValueShare
import com.vitorpamplona.quartz.utils.mapNotNullAsync
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
/**
* Executes a Podcasting-2.0 value-for-value (V4V) split: takes a [PodcastValue] block and a total
* amount, computes each recipient's share ([PodcastValue.computeShares]) and pays them.
*
* This is the V4V analogue of [ZapPaymentHandler]. The recipients are raw Lightning destinations
* declared in the value block, but the two kinds are paid very differently:
*
* - [PodcastValue.TYPE_LNADDRESS] — resolved to a BOLT-11 via LNURL-pay and paid through the user's
* default payment source (NWC, CLINK debit, or — when none is set — handed to an external wallet
* via [onPayInvoicesViaIntent]). Same rails as a zap, and when `asZap` is set each share also
* carries a NIP-57 zap request, so a Nostr-aware lnaddress provider issues a real **zap receipt**
* (this is what lets the standard zap button drive a V4V split with its usual icon/counter UI).
* Per-minute streaming pays with `asZap = false` to avoid publishing a receipt every minute.
* - [PodcastValue.TYPE_NODE] — paid by **keysend** (NIP-47 `pay_keysend`) carrying the Podcasting-2.0
* boostagram TLV ([PodcastValue.PODCAST_TLV_RECORD]) plus any per-recipient custom TLV. There is no
* LNURL endpoint and no invoice, so keysend can never produce a zap receipt regardless of `asZap`.
* Keysend is only available over NWC, so node recipients are skipped (with an error) when no NWC
* wallet is set up.
*/
class V4VPaymentHandler(
val account: Account,
) {
/** A resolved lnaddress share ready to pay: the share plus the BOLT-11 fetched for it. */
class InvoicePayable(
val share: PodcastValueShare,
val invoice: String,
)
suspend fun pay(
value: PodcastValue,
totalMilliSats: Long,
boostagram: PodcastBoostagram,
zappedNote: Note?,
context: Context,
okHttpClient: (String) -> OkHttpClient,
onError: (title: String, message: String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayInvoicesViaIntent: (invoices: List<String>) -> Unit,
asZap: Boolean = false,
zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
) = withContext(Dispatchers.IO) {
val shares = value.computeShares(totalMilliSats)
if (shares.isEmpty()) {
onError(
stringRes(context, R.string.podcast_value_error_title),
stringRes(context, R.string.podcast_value_no_recipients),
)
return@withContext
}
val nodeShares = shares.filter { it.recipient.type == PodcastValue.TYPE_NODE }
val lnAddressShares = shares.filter { it.recipient.type == PodcastValue.TYPE_LNADDRESS }
onProgress(0.05f)
// Keysend (node) recipients can only be paid over NWC.
if (nodeShares.isNotEmpty()) {
if (account.nip47SignerState.hasWalletConnectSetup()) {
payNodeSharesViaKeysend(nodeShares, boostagram, context, onError)
} else {
onError(
stringRes(context, R.string.podcast_value_error_title),
stringRes(context, R.string.podcast_value_keysend_requires_nwc),
)
}
}
if (lnAddressShares.isNotEmpty()) {
val payables =
assembleInvoices(
shares = lnAddressShares,
message = boostagram.message.orEmpty(),
asZap = asZap,
zapType = zapType,
zappedNote = zappedNote,
okHttpClient = okHttpClient,
context = context,
onError = onError,
onProgress = { onProgress(it * 0.6f + 0.1f) },
)
payInvoices(payables, zappedNote, context, onError, onPayInvoicesViaIntent) {
onProgress(it * 0.25f + 0.7f)
}
}
onProgress(1f)
}
/** Hex-encodes a TLV value string as NIP-47 `pay_keysend` requires (UTF-8 bytes → hex). */
private fun hexTlv(value: String): String = value.encodeToByteArray().toHexKey()
private suspend fun payNodeSharesViaKeysend(
shares: List<PodcastValueShare>,
boostagram: PodcastBoostagram,
context: Context,
onError: (String, String) -> Unit,
) {
val metadataTlv = TlvRecord(PodcastValue.PODCAST_TLV_RECORD, hexTlv(boostagram.toJson()))
shares.forEach { share ->
val pubkey = share.recipient.address ?: return@forEach
val tlvRecords = mutableListOf(metadataTlv)
val customType = share.recipient.customKey?.toLongOrNull()
val customValue = share.recipient.customValue
if (customType != null && customValue != null) {
tlvRecords.add(TlvRecord(customType, hexTlv(customValue)))
}
val request =
PayKeysendMethod.create(
amount = share.amountMilliSats,
pubkey = pubkey,
tlvRecords = tlvRecords,
)
account.sendNwcRequest(request) { response: Response? ->
if (response is IErrorResponseLike) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
response.errorMessage()
?: stringRes(context, R.string.error_parsing_error_message),
)
}
}
}
}
private suspend fun assembleInvoices(
shares: List<PodcastValueShare>,
message: String,
asZap: Boolean,
zapType: LnZapEvent.ZapType,
zappedNote: Note?,
okHttpClient: (String) -> OkHttpClient,
context: Context,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
): List<InvoicePayable> {
// When paying as a zap, attach a NIP-57 request to each share so the recipient's LNURL
// provider mints a zappable invoice and publishes a receipt. The receipt is attributed to
// the zapped note (toUser = null) since a value-block lnaddress is a raw payee, not
// necessarily a Nostr identity. Send to the show/episode author's inbox so they see it.
val noteEvent = zappedNote?.event
val authorRelays = zappedNote?.author?.inboxRelays()?.toSet() ?: emptySet()
var progress = 0f
return mapNotNullAsync(shares) { share: PodcastValueShare ->
val lnAddress = share.recipient.address ?: return@mapNotNullAsync null
try {
val nostrRequest =
if (asZap && noteEvent != null) {
account.createZapRequestFor(
event = noteEvent,
pollOption = null,
message = message,
zapType = zapType,
toUser = null,
additionalRelays = authorRelays,
amountMillisats = share.amountMilliSats,
lnurl = LnurlForm.toUrl(lnAddress)?.let(LnurlForm::urlToBech32),
)
} else {
null
}
val invoice =
LightningAddressResolver().lnAddressInvoice(
lnAddress = lnAddress,
milliSats = share.amountMilliSats,
message = message,
nostrRequest = nostrRequest,
okHttpClient = okHttpClient,
onProgress = {},
context = context,
)
progress += 1f / shares.size
onProgress(progress)
InvoicePayable(share, invoice)
} catch (e: LightningAddressResolver.LightningAddressError) {
onError(e.title, e.msg)
null
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
e.message ?: stringRes(context, R.string.error_parsing_error_message),
)
null
}
}
}
private suspend fun payInvoices(
payables: List<InvoicePayable>,
zappedNote: Note?,
context: Context,
onError: (String, String) -> Unit,
onPayInvoicesViaIntent: (List<String>) -> Unit,
onProgress: (percent: Float) -> Unit,
) {
if (payables.isEmpty()) return
when (val source = account.settings.defaultPaymentSource()) {
is PaymentSource.Nwc -> {
var done = 0
payables.forEach { payable ->
account.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
if (response is IErrorResponseLike) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
response.errorMessage()
?: stringRes(context, R.string.error_parsing_error_message),
)
}
}
done++
onProgress(done.toFloat() / payables.size)
}
}
is PaymentSource.ClinkDebit -> {
var done = 0
payables.forEach { payable ->
val response = ClinkDebitPayer.payInvoice(account, source.wallet.pointer, payable.invoice)
if (response?.isOk() != true) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
response?.failureDetail()
?: stringRes(context, R.string.clink_debit_no_response),
)
}
done++
onProgress(done.toFloat() / payables.size)
}
}
null -> {
onPayInvoicesViaIntent(payables.map { it.invoice })
onProgress(1f)
}
}
}
}
@@ -0,0 +1,64 @@
/*
* 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.podcasts
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
/**
* Fetches the off-event side files a podcast episode references by URL — the Podcasting-2.0
* `chapters.json` document and the `transcript` file — so the client can render them in-app.
* A bounded read cap keeps a hostile/huge file from blowing up memory.
*/
object PodcastRemoteContent {
/** Refuse bodies larger than this (chapters/transcripts are small text files). */
private const val MAX_BYTES = 2_000_000L
suspend fun fetchText(
url: String,
okHttpClient: OkHttpClient,
): String? =
withContext(Dispatchers.IO) {
try {
val request =
Request
.Builder()
.url(url)
.get()
.build()
okHttpClient.newCall(request).executeAsync().use { response ->
if (!response.isSuccessful) return@use null
val body = response.body ?: return@use null
// Reject an oversized declared length outright; cap the read for chunked bodies.
if (body.contentLength() > MAX_BYTES) return@use null
body.string().take(MAX_BYTES.toInt())
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
}
}
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLi
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NsitesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.MyPodcastFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodcastFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler
@@ -145,6 +146,7 @@ class RelaySubscriptionsCoordinator(
val podcastEpisodes = PodcastEpisodesFilterAssembler(client)
val podcasts = PodcastsFilterAssembler(client)
val onePodcast = OnePodcastFilterAssembler(client)
val myPodcast = MyPodcastFilterAssembler(client)
val softwareApps = SoftwareAppsFilterAssembler(client)
val napplets = NappletsFilterAssembler(client)
val connectedApps = ConnectedAppsFilterAssembler(client)
@@ -198,6 +200,7 @@ class RelaySubscriptionsCoordinator(
podcastEpisodes,
podcasts,
onePodcast,
myPodcast,
softwareApps,
badges,
profileBadges,
@@ -44,6 +44,7 @@ import okio.buffer
import okio.sink
import okio.source
import java.io.File
import java.io.IOException
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@@ -53,6 +54,7 @@ object MediaSaverToDisk {
okHttpClient: (String) -> OkHttpClient,
mimeType: String?,
localContext: Context,
resolveBlossom: suspend (String) -> String? = { null },
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) = withContext(Dispatchers.IO) {
@@ -77,6 +79,7 @@ object MediaSaverToDisk {
mimeType = mimeType,
okHttpClient = okHttpClient,
context = localContext,
resolveBlossom = resolveBlossom,
onSuccess = onSuccess,
onError = onError,
)
@@ -87,6 +90,11 @@ object MediaSaverToDisk {
/**
* Saves the image to the gallery. May require a storage permission.
*
* `blossom:` (BUD-10) URIs are resolved to a concrete `http(s)` server URL
* via [resolveBlossom] before downloading, since OkHttp only speaks
* http/https. When resolution fails the save reports an error instead of
* crashing with "expected scheme http or https but was blossom".
*
* @see AMETHYST_SUBDIRECTORY
*/
suspend fun downloadAndSave(
@@ -94,26 +102,35 @@ object MediaSaverToDisk {
mimeType: String?,
okHttpClient: (String) -> OkHttpClient,
context: Context,
resolveBlossom: suspend (String) -> String? = { null },
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) {
try {
val client = okHttpClient(url)
val downloadUrl =
if (url.startsWith(BLOSSOM_SCHEME, ignoreCase = true)) {
resolveBlossom(url)
?: throw IOException("Could not find a Blossom server that hosts $url")
} else {
url
}
val client = okHttpClient(downloadUrl)
val request =
Request
.Builder()
.get()
.url(url)
.url(downloadUrl)
.build()
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
check(response.isSuccessful) {
"Failed to download $url: HTTP ${response.code} ${response.message}"
"Failed to download $downloadUrl: HTTP ${response.code} ${response.message}"
}
val trimmedUrl = trimInlineMetaData(url)
val trimmedUrl = trimInlineMetaData(downloadUrl)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val headerType =
response
@@ -292,4 +309,5 @@ object MediaSaverToDisk {
private const val AMETHYST_SUBDIRECTORY = "Amethyst"
private const val PDF_MIME_TYPE = "application/pdf"
private const val BLOSSOM_SCHEME = "blossom:"
}
@@ -83,6 +83,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -532,6 +533,11 @@ internal suspend fun saveMediaToGallery(
}
},
localContext,
resolveBlossom = {
Amethyst.instance.blossomResolver
.findServers(it)
?.serverUrl
},
onSuccess = {
showToastOnMain(localContext, success)
},
@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.BookmarkedPodcastsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen
@@ -179,6 +180,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.PinnedNotesScre
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastEpisodesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.EditPodcastShowScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodcastEpisodeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodcastTrailerScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.PodcastAuthoringScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
@@ -373,6 +378,10 @@ fun BuildNavigation(
composableFromEnd<Route.PodcastEpisodes> { PodcastEpisodesScreen(accountViewModel, nav) }
composableFromEnd<Route.Podcasts> { PodcastsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.Podcast> { PodcastScreen(it.pubkey, accountViewModel, nav) }
composableFromEnd<Route.PodcastAuthoring> { PodcastAuthoringScreen(accountViewModel, nav) }
composableFromEnd<Route.EditPodcastShow> { EditPodcastShowScreen(accountViewModel, nav) }
composableFromEndArgs<Route.NewPodcastEpisode> { NewPodcastEpisodeScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
composableFromEnd<Route.NewPodcastTrailer> { NewPodcastTrailerScreen(accountViewModel, nav) }
composableFromEndArgs<Route.NewMusicTrack> { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
composableFromEndArgs<Route.NewMusicPlaylist> { NewMusicPlaylistScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
composableFromEndArgs<Route.AddToMusicPlaylist> { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) }
@@ -446,6 +455,7 @@ fun BuildNavigation(
composableFromEnd<Route.OldBookmarks> { OldBookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) }
composableFromEnd<Route.BookmarkedRepositories> { BookmarkedRepositoriesScreen(accountViewModel, nav) }
composableFromEnd<Route.BookmarkedPodcasts> { BookmarkedPodcastsScreen(accountViewModel, nav) }
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
composableFromEnd<Route.ScheduledPosts> { ScheduledPostsScreen(accountViewModel, nav) }
@@ -196,6 +196,17 @@ sealed class Route {
val pubkey: String,
) : Route()
@Serializable object PodcastAuthoring : Route()
@Serializable object EditPodcastShow : Route()
@Serializable
data class NewPodcastEpisode(
val dTag: String? = null,
) : Route()
@Serializable object NewPodcastTrailer : Route()
@Serializable
data class NewMusicTrack(
val dTag: String? = null,
@@ -304,6 +315,8 @@ sealed class Route {
@Serializable object BookmarkedRepositories : Route()
@Serializable object BookmarkedPodcasts : Route()
@Serializable object BookmarkGroups : Route()
@Serializable object InterestSets : Route()
@@ -44,13 +44,14 @@ import com.vitorpamplona.amethyst.ui.theme.isLight
fun TopBarExtensibleWithBackButton(
title: @Composable RowScope.() -> Unit,
extendableRow: (@Composable () -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
popBack: () -> Unit,
) {
MyExtensibleTopAppBar(
title = title,
extendableRow = extendableRow,
navigationIcon = { IconButton(onClick = popBack) { ArrowBackIcon() } },
actions = {},
actions = actions,
)
}
@@ -198,6 +198,7 @@ import com.vitorpamplona.amethyst.ui.note.types.observeZapSender
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay
import com.vitorpamplona.amethyst.ui.stringRes
@@ -325,6 +326,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
@@ -344,6 +346,9 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@@ -982,10 +987,39 @@ private fun RenderNoteRow(
RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
}
is Podcasting20EpisodeEvent -> {
RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
}
is Podcasting20TrailerEvent -> {
PodcastTrailerListItem(baseNote, accountViewModel, nav)
}
is PodcastMetadataEvent -> {
RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
}
is AppSpecificDataEvent -> {
// kind:30078 is overloaded; only the Podcasting-2.0 show-metadata variant renders as a
// podcast card. Anything else (e.g. a client's own settings) keeps the text fallback.
if (noteEvent.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) {
RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
} else {
RenderTextEvent(
baseNote,
makeItShort,
canPreview,
quotesLeft,
unPackReply,
backgroundColor,
editState,
accountViewModel,
nav,
isBoostedNote = isBoostedNote,
)
}
}
is DraftWrapEvent -> {
RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav)
}
@@ -0,0 +1,109 @@
/*
* 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.note.types
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* A bookmark toggle for a podcast (show or episode). "Favoriting"/subscribing to a podcast reuses
* the existing NIP-51 bookmark list (kind 10003) — a public bookmark matches the soft public
* recommendation the dedicated favorites list (10054) was meant for, and the note then appears in
* the standard Bookmarks screen. Works for both regular events (e-tag) and addressable shows/
* episodes (a-tag) because [AccountViewModel.addPublicBookmark] branches on the note type.
*
* Bookmarked state is read from the public bookmark id/address **sets** (not `List<Note>`
* containment) so it reflects reliably for addressable notes and updates the moment the list
* changes — the icon flipping filled/outline is the feedback, no toast needed.
*
* Rendered as a compact clickable glyph (not a Material [androidx.compose.material3.IconButton],
* whose 48dp minimum touch target would stand taller than the title it sits beside). [iconSize]
* defaults to a single title line so it aligns when placed next to a show/episode title.
*/
@Composable
fun PodcastBookmarkButton(
note: Note,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
iconSize: Dp = 20.dp,
) {
val bookmarkState = accountViewModel.account.bookmarkState
val publicAddresses by bookmarkState.publicBookmarkAddressIdSet.collectAsStateWithLifecycle()
val publicEvents by bookmarkState.publicBookmarkEventIdSet.collectAsStateWithLifecycle()
val isBookmarked =
remember(note, publicAddresses, publicEvents) {
if (note is AddressableNote) {
note.address in publicAddresses
} else {
note.idHex in publicEvents
}
}
Box(
modifier =
modifier
.clip(CircleShape)
.clickable(
role = Role.Button,
onClick = {
if (isBookmarked) {
accountViewModel.removePublicBookmark(note)
} else {
accountViewModel.addPublicBookmark(note)
}
},
).padding(4.dp),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd,
contentDescription =
stringRes(
if (isBookmarked) R.string.remove_from_public_bookmarks else R.string.add_to_public_bookmarks,
),
tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(iconSize),
)
}
}
@@ -0,0 +1,161 @@
/*
* 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.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.podcasts.PodcastChapter
import com.vitorpamplona.quartz.podcasts.PodcastChapters
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
private sealed interface ChaptersUiState {
data object Loading : ChaptersUiState
data class Loaded(
val chapters: List<PodcastChapter>,
) : ChaptersUiState
data object Failed : ChaptersUiState
}
/**
* Fetches the episode's off-event Podcasting-2.0 chapters document on first composition and renders
* it as a tinted list of `timestamp — title` rows. Fetch is lazy (callers gate it behind an expand
* toggle) so scrolling a feed never triggers network. On failure or empty, renders nothing.
*/
@Composable
fun PodcastChaptersSection(
chaptersUrl: String,
accountViewModel: AccountViewModel,
) {
val state by produceState<ChaptersUiState>(ChaptersUiState.Loading, chaptersUrl) {
val client = accountViewModel.httpClientBuilder.okHttpClientForPreview(chaptersUrl)
val parsed = loadChapters(chaptersUrl, client)
value = if (parsed != null) ChaptersUiState.Loaded(parsed.chapters) else ChaptersUiState.Failed
}
when (val current = state) {
is ChaptersUiState.Loading ->
Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
is ChaptersUiState.Failed -> {}
is ChaptersUiState.Loaded -> {
val chapters = current.chapters
if (chapters.isEmpty()) return
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
chapters.forEach { chapter ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = formatTimestamp(chapter.startSeconds()),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.widthIn(min = 44.dp),
)
Text(
text = chapter.title ?: stringRes(R.string.podcast_chapters),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
private suspend fun loadChapters(
url: String,
client: OkHttpClient,
): PodcastChapters? =
withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(url).build()
client.newCall(request).executeAsync().use { response ->
if (response.isSuccessful) PodcastChapters.parse(response.body.string()) else null
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("PodcastChapters", "Failed to load chapters from $url", e)
null
}
}
private fun formatTimestamp(seconds: Long): String {
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
val secs = seconds % 60
return if (hours > 0) {
"%d:%02d:%02d".format(hours, minutes, secs)
} else {
"%d:%02d".format(minutes, secs)
}
}
@@ -0,0 +1,167 @@
/*
* 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.note.types
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.podcasts.PodcastRemoteContent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.podcasts.PodcastChapter
import com.vitorpamplona.quartz.podcasts.PodcastChapters
/**
* The episode's Podcasting-2.0 chapters (from the off-event `chapters.json` referenced by the
* `chapters` tag), fetched and rendered as a collapsible, tappable list. Tapping a chapter calls
* [onSeek] with its start in milliseconds so the host can seek the live media controller — the same
* contract as [PodcastSoundbites].
*/
@Composable
fun PodcastChaptersView(
chaptersUrl: String,
onSeek: (startMillis: Long) -> Unit,
accountViewModel: AccountViewModel,
) {
val chapters by produceState(initialValue = emptyList<PodcastChapter>(), chaptersUrl) {
val client = accountViewModel.httpClientBuilder.okHttpClientForPreview(chaptersUrl)
val body = PodcastRemoteContent.fetchText(chaptersUrl, client)
value = body?.let { PodcastChapters.parse(it)?.chapters }?.filter { it.title?.isNotBlank() == true } ?: emptyList()
}
if (chapters.isEmpty()) return
var expanded by remember(chaptersUrl) { mutableStateOf(false) }
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
CollapsibleHeader(
symbol = MaterialSymbols.AutoMirrored.FormatListBulleted,
title = pluralStringResource(R.plurals.podcast_chapters_count, chapters.size, chapters.size),
expanded = expanded,
onToggle = { expanded = !expanded },
)
if (expanded) {
chapters.forEach { chapter ->
ChapterRow(chapter, onSeek)
}
}
}
}
@Composable
private fun ChapterRow(
chapter: PodcastChapter,
onSeek: (Long) -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onSeek(chapter.startSeconds() * 1000) }
.padding(horizontal = 4.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = formatChapterTime(chapter.startTime),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
Text(
text = chapter.title.orEmpty(),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
@Composable
internal fun CollapsibleHeader(
symbol: MaterialSymbol,
title: String,
expanded: Boolean,
onToggle: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = symbol,
contentDescription = null,
modifier = Size18Modifier,
tint = MaterialTheme.colorScheme.grayText,
)
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
Icon(
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.grayText,
)
}
}
private fun formatChapterTime(totalSeconds: Double): String {
val total = totalSeconds.toLong()
val h = total / 3600
val m = (total % 3600) / 60
val s = total % 60
val two = { n: Long -> if (n < 10) "0$n" else "$n" }
return if (h > 0) "$h:${two(m)}:${two(s)}" else "$m:${two(s)}"
}
@@ -0,0 +1,111 @@
/*
* 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.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
/**
* A small rounded, tinted pill for a podcast attribute (explicit, completed, a genre, a season/
* episode number, …). Shared by the show and episode renderers so they read as one design.
*/
@Composable
internal fun PodcastBadge(
label: String,
symbol: MaterialSymbol?,
container: Color,
content: Color,
) {
Row(
modifier =
Modifier
.clip(RoundedCornerShape(50))
.background(container)
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
symbol?.let {
Icon(
symbol = it,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = content,
)
}
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = content,
)
}
}
/** A clickable pill that opens an external resource (a website, a transcript, chapters, …). */
@Composable
internal fun PodcastLinkChip(
label: String,
symbol: MaterialSymbol,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
symbol = symbol,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -30,27 +33,36 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
// Bottom-rounded border on the audio player so it visually butts up against the cover's
// top-rounded corners as one card. Constant — keep out of recomposition.
private val PLAYER_BORDER_MODIFIER =
Modifier.clip(RoundedCornerShape(bottomStart = 15.dp, bottomEnd = 15.dp))
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderPodcastEpisode(
note: Note,
@@ -60,21 +72,32 @@ fun RenderPodcastEpisode(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? PodcastEpisodeEvent ?: return
val noteEvent = note.event ?: return
// Both NIP-F4 (kind 54) and Podcasting-2.0 (kind 30054) episodes implement PodcastEpisode,
// so this one renderer serves both. Title/image/description/audio come through the shared
// abstraction; content and tags come from the underlying event.
val episode = noteEvent as? PodcastEpisode ?: return
val title = remember(noteEvent) { noteEvent.title() }
val image = remember(noteEvent) { noteEvent.image() }
val description = remember(noteEvent) { noteEvent.description() }
// Pick the first audio URL. Publishers may emit multiple containers in their preferred
// order; clients with codec preferences can extend this later.
val firstAudio = remember(noteEvent) { noteEvent.audios().firstOrNull() }
val title = remember(noteEvent) { episode.episodeTitle() }
val image = remember(noteEvent) { episode.episodeImage() }
val description = remember(noteEvent) { episode.episodeDescription() }
// Prefer audio (podcasts are audio-first); fall back to the video source if that's all the
// episode ships. The media-controller player handles both.
val media = remember(noteEvent) { episode.episodeAudio().firstOrNull() ?: episode.episodeVideo() }
val hasVideo = remember(noteEvent) { episode.episodeVideo() != null }
val season = remember(noteEvent) { episode.episodeSeason() }
val episodeNumber = remember(noteEvent) { episode.episodeNumber() }
val transcriptUrl = remember(noteEvent) { episode.episodeTranscriptUrl() }
val chaptersUrl = remember(noteEvent) { episode.episodeChaptersUrl() }
val value = remember(noteEvent) { episode.episodeValue() }
var chaptersExpanded by remember(noteEvent) { mutableStateOf(false) }
// Suppress the markdown block if blank — title + description already describe a short
// episode. Otherwise hand off to RichText below.
val markdown = remember(noteEvent) { noteEvent.content.ifBlank { null } }
Column(MaterialTheme.colorScheme.replyModifier) {
PodcastCoverCard(image, note, accountViewModel)
firstAudio?.let { audio ->
media?.let { audio ->
PodcastEpisodeAudioPlayer(
audio = audio,
note = note,
@@ -92,15 +115,67 @@ fun RenderPodcastEpisode(
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
PodcastBookmarkButton(note, accountViewModel)
}
if (season != null || episodeNumber != null || hasVideo || transcriptUrl != null || chaptersUrl != null) {
val uriHandler = LocalUriHandler.current
val seasonEpisodeLabel =
when {
season != null && episodeNumber != null -> stringRes(R.string.podcast_season_episode, season, episodeNumber)
episodeNumber != null -> stringRes(R.string.podcast_episode_number, episodeNumber)
season != null -> stringRes(R.string.podcast_season, season)
else -> null
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
seasonEpisodeLabel?.let {
PodcastBadge(
label = it,
symbol = null,
container = MaterialTheme.colorScheme.secondaryContainer,
content = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
if (hasVideo) {
PodcastBadge(
label = stringRes(R.string.podcast_video),
symbol = MaterialSymbols.Videocam,
container = MaterialTheme.colorScheme.tertiaryContainer,
content = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
transcriptUrl?.let { url ->
PodcastLinkChip(stringRes(R.string.podcast_transcript), MaterialSymbols.Description) {
runCatching { uriHandler.openUri(url) }
}
}
chaptersUrl?.let {
PodcastLinkChip(stringRes(R.string.podcast_chapters), MaterialSymbols.Checklist) {
chaptersExpanded = !chaptersExpanded
}
}
}
}
if (chaptersExpanded) {
chaptersUrl?.let { PodcastChaptersSection(it, accountViewModel) }
}
description?.let {
@@ -119,6 +194,18 @@ fun RenderPodcastEpisode(
)
}
value?.takeIf { !makeItShort }?.let {
PodcastValueSplits(value = it)
}
if (!makeItShort) {
val persons = remember(noteEvent) { episode.episodePersons() }
PodcastPeople(persons, accountViewModel, nav)
val transcriptUrl = remember(noteEvent) { episode.episodeTranscriptUrl() }
transcriptUrl?.let { PodcastTranscriptView(it, accountViewModel) }
}
markdown?.takeIf { !makeItShort }?.let {
Spacer(Modifier.padding(top = 4.dp))
val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() }
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -35,7 +36,8 @@ import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.syntheticWaveformFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
// The voice player's internal controls are laid out for 100.dp; 80.dp is the tightest height
// that still fits the play button without clipping it. Shared so the feed card and the
@@ -43,13 +45,14 @@ import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag
private val PLAYER_HEIGHT_MODIFIER = Modifier.fillMaxWidth().height(80.dp)
/**
* The inline audio strip for a NIP-F4 episode (kind 54): one [AudioTag] played through the
* shared media-controller stack. [borderModifier] shapes the strip — bottom-rounded when it
* butts up under a cover image, fully rounded when it stands alone in a list.
* The inline audio strip for a podcast episode: one [PodcastAudio] played through the shared
* media-controller stack. Works for both NIP-F4 (kind 54) and Podcasting-2.0 (kind 30054)
* episodes via the spec-neutral audio holder. [borderModifier] shapes the strip — bottom-rounded
* when it butts up under a cover image, fully rounded when it stands alone in a list.
*/
@Composable
fun PodcastEpisodeAudioPlayer(
audio: AudioTag,
audio: PodcastAudio,
note: Note,
title: String?,
image: String?,
@@ -69,10 +72,18 @@ fun PodcastEpisodeAudioPlayer(
?: syntheticWaveformFor(note.idHex)
}
Row(
PLAYER_HEIGHT_MODIFIER,
verticalAlignment = Alignment.CenterVertically,
) {
// The episode's value-for-value block, if any — drives the per-minute streaming control below
// the player. Pulled through the spec-neutral PodcastEpisode interface so both kinds work.
val value = remember(note) { (note.event as? PodcastEpisode)?.episodeValue() }
// Highlight clips (Podcasting-2.0 soundbites) — rendered under the player so a tap can seek the
// live controller to the clip's start.
val soundbites = remember(note) { (note.event as? PodcastEpisode)?.episodeSoundbites().orEmpty() }
// Off-event chapters document URL, if any — the list seeks the live controller too.
val chaptersUrl = remember(note) { (note.event as? PodcastEpisode)?.episodeChaptersUrl() }
Column(Modifier.fillMaxWidth()) {
GetMediaItem(
videoUri = audio.url,
title = title,
@@ -90,13 +101,45 @@ fun PodcastEpisodeAudioPlayer(
muted = false,
) { controller ->
PauseControllerWhenInBackground(controller)
RenderVoicePlayer(
mediaItem = mediaItem,
controllerState = controller,
waveform = waveform,
borderModifier = borderModifier,
accountViewModel = accountViewModel,
)
Row(
PLAYER_HEIGHT_MODIFIER,
verticalAlignment = Alignment.CenterVertically,
) {
RenderVoicePlayer(
mediaItem = mediaItem,
controllerState = controller,
waveform = waveform,
borderModifier = borderModifier,
accountViewModel = accountViewModel,
)
}
value?.let {
PodcastStreamingControl(
value = it,
note = note,
episodeName = title,
podcastName = null,
controllerState = controller,
accountViewModel = accountViewModel,
)
}
PodcastSoundbites(soundbites) { startMillis ->
controller.controller.seekTo(startMillis)
controller.controller.play()
}
chaptersUrl?.let { url ->
PodcastChaptersView(
chaptersUrl = url,
onSeek = { startMillis ->
controller.controller.seekTo(startMillis)
controller.controller.play()
},
accountViewModel = accountViewModel,
)
}
}
}
}
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -37,6 +38,7 @@ 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.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -53,7 +55,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.resolvePodcastShow
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -65,14 +67,23 @@ fun RenderPodcastMetadata(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? PodcastMetadataEvent ?: return
val noteEvent = note.event ?: return
// Resolves NIP-F4 kind:10154 and Podcasting-2.0 kind:30078 shows to one PodcastShow view.
val show = remember(noteEvent) { resolvePodcastShow(noteEvent) } ?: return
val title = remember(noteEvent) { noteEvent.title() }
val image = remember(noteEvent) { noteEvent.image() }
val description = remember(noteEvent) { noteEvent.description() }
val websites = remember(noteEvent) { noteEvent.websites() }
// Each podcast is its own keypair, so the author pubkey IS the podcast id used to open
// its dedicated screen with the full episode list.
val title = remember(noteEvent) { show.showTitle() }
val author = remember(noteEvent) { show.showAuthor() }
val image = remember(noteEvent) { show.showImage() }
val description = remember(noteEvent) { show.showDescription() }
val websites = remember(noteEvent) { show.showWebsites() }
val categories = remember(noteEvent) { show.showCategories() }
val fundingUrls = remember(noteEvent) { show.showFundingUrls() }
val isExplicit = remember(noteEvent) { show.showIsExplicit() }
val isComplete = remember(noteEvent) { show.showIsComplete() }
val copyright = remember(noteEvent) { show.showCopyright() }
val value = remember(noteEvent) { show.showValue() }
// In both drafts the show's author pubkey IS the podcast id used to open its dedicated
// screen with the full episode list (episodes are authored by the same key).
val podcastPubkey = remember(noteEvent) { noteEvent.pubKey }
Column(
@@ -89,17 +100,65 @@ fun RenderPodcastMetadata(
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
title?.let {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
PodcastBookmarkButton(note, accountViewModel)
}
author?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
maxLines = 2,
text = stringRes(R.string.podcast_by_author, it),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
if (isExplicit || isComplete || categories.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
if (isComplete) {
PodcastBadge(
label = stringRes(R.string.podcast_completed),
symbol = MaterialSymbols.CheckCircle,
container = MaterialTheme.colorScheme.tertiaryContainer,
content = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
if (isExplicit) {
PodcastBadge(
label = stringRes(R.string.podcast_explicit),
symbol = null,
container = MaterialTheme.colorScheme.errorContainer,
content = MaterialTheme.colorScheme.onErrorContainer,
)
}
categories.forEach { category ->
PodcastBadge(
label = category,
symbol = MaterialSymbols.Tag,
container = MaterialTheme.colorScheme.secondaryContainer,
content = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
description?.takeIf { !makeItShort }?.let {
val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() }
@@ -116,23 +175,51 @@ fun RenderPodcastMetadata(
)
}
value?.takeIf { !makeItShort }?.let {
PodcastValueSplits(value = it)
}
if (fundingUrls.isNotEmpty() && !makeItShort) {
val uriHandler = LocalUriHandler.current
Button(
onClick = { runCatching { uriHandler.openUri(fundingUrls.first()) } },
modifier = Modifier.fillMaxWidth().padding(top = Size5dp),
) {
Icon(
symbol = MaterialSymbols.Favorite,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onPrimary,
)
Text(
text = stringRes(R.string.podcast_support_show),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(start = 8.dp),
)
}
}
if (websites.isNotEmpty() && !makeItShort) {
val uriHandler = LocalUriHandler.current
FlowRow(
horizontalArrangement = Arrangement.spacedBy(Size5dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
websites.forEach { website ->
Text(
text = website,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
PodcastLinkChip(website, MaterialSymbols.Public) { runCatching { uriHandler.openUri(website) } }
}
}
}
copyright?.takeIf { !makeItShort }?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(top = Size5dp),
)
}
// Affordance that this card opens a full show page with every episode.
Row(
modifier = Modifier.fillMaxWidth().padding(top = Size5dp),
@@ -0,0 +1,206 @@
/*
* 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.note.types
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.LocalUriHandler
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.podcasts.PodcastPerson
/**
* A "Hosts & Guests" strip: the Podcasting-2.0 `podcast:person` credits for a show or episode,
* rendered as a horizontally scrollable row of avatar + name + role.
*
* A person is usually a free-text credit (name + image URL + web link), not a Nostr user, so it's
* drawn with the app's default profile-image loader and its link opens externally. But when the
* publisher's `href` points at an `npub`/`nprofile`, we upgrade the card to a real Nostr profile —
* the standard [ClickableUserPicture] + [UsernameDisplay], tappable through to the profile.
*/
@Composable
fun PodcastPeople(
persons: List<PodcastPerson>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val people = persons.filter { it.isValid() }
if (people.isEmpty()) return
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringRes(R.string.podcast_hosts_and_guests),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.grayText,
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(people) { person ->
PersonItem(person, accountViewModel, nav)
}
}
}
}
@Composable
private fun PersonItem(
person: PodcastPerson,
accountViewModel: AccountViewModel,
nav: INav,
) {
val pubKey = remember(person) { person.nostrPubKey() }
if (pubKey != null) {
LoadUser(pubKey, accountViewModel) { user ->
if (user != null) {
NostrPersonCard(user, person.role, accountViewModel, nav)
} else {
FreeTextPersonCard(person, accountViewModel)
}
}
} else {
FreeTextPersonCard(person, accountViewModel)
}
}
/** A person that resolved to a real Nostr identity — the standard profile treatment. */
@Composable
private fun NostrPersonCard(
user: User,
role: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
PersonCardScaffold(
onClick = { nav.nav(routeFor(user)) },
role = role,
avatar = { ClickableUserPicture(user, 56.dp, accountViewModel) },
name = {
UsernameDisplay(
user,
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
accountViewModel = accountViewModel,
)
},
)
}
/** A free-text `podcast:person` credit — default image loader, external link. */
@Composable
private fun FreeTextPersonCard(
person: PodcastPerson,
accountViewModel: AccountViewModel,
) {
val uriHandler = LocalUriHandler.current
val href = person.href
PersonCardScaffold(
onClick = href?.let { { runCatching { uriHandler.openUri(it) } } },
role = person.role,
avatar = {
RobohashFallbackAsyncImage(
robot = person.name,
model = person.img,
contentDescription = person.name,
modifier = Modifier.size(56.dp).clip(CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
},
name = {
Text(
text = person.name,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
},
)
}
/** Shared 72dp centered card layout: avatar, name, and an optional role line. */
@Composable
private fun PersonCardScaffold(
onClick: (() -> Unit)?,
role: String?,
avatar: @Composable () -> Unit,
name: @Composable () -> Unit,
) {
Column(
modifier =
Modifier
.width(72.dp)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
avatar()
name()
role?.takeIf { it.isNotEmpty() }?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@@ -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.ui.note.types
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.podcasts.PodcastSoundbite
/**
* The episode's Podcasting-2.0 `podcast:soundbite` highlight clips as "jump to the good part" chips.
* Tapping one calls [onPlayFrom] with the clip's start offset in milliseconds; the host wires that
* to the media controller so playback seeks there. Kept controller-agnostic so it can live wherever
* a seek callback is available.
*/
@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
@Composable
fun PodcastSoundbites(
soundbites: List<PodcastSoundbite>,
onPlayFrom: (startMillis: Long) -> Unit,
) {
if (soundbites.isEmpty()) return
FlowRow(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
soundbites.forEach { soundbite ->
val label = soundbite.title?.takeIf { it.isNotBlank() } ?: formatClock(soundbite.startTimeSeconds)
AssistChip(
onClick = { onPlayFrom(soundbite.startMillis()) },
label = {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
)
},
leadingIcon = {
Icon(
symbol = MaterialSymbols.PlayArrow,
contentDescription = stringRes(R.string.podcast_play_soundbite),
modifier = Modifier.size(AssistChipDefaults.IconSize),
tint = MaterialTheme.colorScheme.primary,
)
},
)
}
}
}
private fun formatClock(totalSeconds: Double): String {
val total = totalSeconds.toLong()
val minutes = total / 60
val seconds = total % 60
val secStr = if (seconds < 10) "0$seconds" else "$seconds"
return "$minutes:$secStr"
}
@@ -0,0 +1,225 @@
/*
* 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.note.types
import android.content.Context
import android.media.AudioManager
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.podcasts.PodcastStreamingAccrual
import com.vitorpamplona.quartz.podcasts.PodcastValue
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
// How often the accrual loop checks whether audio is genuinely playing. Cheap (one wake/second),
// and re-reading the live isPlaying each tick is what keeps streaming honest: if the player paused,
// went to the background, lost audio focus, errored, or was released, we simply stop accruing.
private const val STREAM_TICK_MS = 1_000L
// Per-minute rates the user can pick from. Deliberately small and explicit — streaming sends sats
// automatically, so the choices are bounded and the selected rate is always shown on the toggle.
private val STREAM_RATE_CHOICES = listOf(1L, 5L, 10L, 21L, 50L, 100L)
private const val DEFAULT_STREAM_RATE = 10L
/**
* Per-minute "streaming" payments for a podcast episode (Podcasting-2.0 value-for-value, streaming
* model). A switch — **off by default** — that, while on, sends the show/episode's [PodcastValue]
* split once per full minute of playback, at the chosen sats/minute rate.
*
* The whole point is that it must never pay while the user isn't listening, so accrual is bound
* tightly to real playback:
* - It lives inside the player composable, so navigating away disposes it and stops streaming.
* - Every second it re-reads the live [MediaControllerState.controller] and only accrues when audio
* is genuinely **audible**: playing, no playback error, in-app volume > 0, and system media volume
* > 0. `isPlaying` alone is not enough — a muted player (the player's mute button sets volume to 0)
* or a system volume of 0 keeps `isPlaying` true while the user hears nothing, and we must not
* spend then. The player also pauses itself on background / off-screen / error, stopping accrual.
* - Only whole, actually-played minutes are billed ([PodcastStreamingAccrual]); a partial minute is
* dropped when the session ends.
* - The toggle is gated to an in-app wallet (NWC/CLINK debit). Streaming to an external wallet app
* would mean firing a payment intent every minute, which we never do.
*/
@Composable
fun PodcastStreamingControl(
value: PodcastValue,
note: Note,
episodeName: String?,
podcastName: String?,
controllerState: MediaControllerState,
accountViewModel: AccountViewModel,
) {
val payableRecipients = remember(value) { value.recipients.count { it.split > 0 && !it.address.isNullOrBlank() } }
if (payableRecipients == 0) return
val context = LocalContext.current
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager }
val hasInAppWallet = remember { accountViewModel.account.settings.defaultPaymentSource() != null }
// Deliberately plain remember (not rememberSaveable): streaming must never silently resume after
// a rotation or process death. Any fresh creation of this control starts OFF; the user re-opts in.
var enabled by remember(note.idHex) { mutableStateOf(false) }
var rate by remember(note.idHex) { mutableStateOf(DEFAULT_STREAM_RATE) }
var rateMenuOpen by remember { mutableStateOf(false) }
var streamedSats by remember(note.idHex) { mutableLongStateOf(0L) }
if (enabled) {
// Restart the loop whenever the toggle, the player, or the rate changes; cancels (and so
// stops streaming) when this composable leaves the tree.
LaunchedEffect(controllerState, rate) {
val accrual = PodcastStreamingAccrual()
while (isActive) {
delay(STREAM_TICK_MS)
// Accrue only when audio is genuinely AUDIBLE, not merely "playing". isPlaying stays
// true when the player is muted (the player's mute button sets volume to 0) or when
// the system media volume is at 0 — in both cases the user hears nothing, so we must
// not spend. Require: playing, no error, in-app volume > 0, and system media volume > 0.
val audible =
runCatching {
controllerState.controller.isPlaying &&
controllerState.playbackError.value == null &&
controllerState.controller.volume > 0.001f &&
(audioManager?.let { it.getStreamVolume(AudioManager.STREAM_MUSIC) > 0 } ?: true)
}.getOrDefault(false)
if (audible) {
val minutes = accrual.accrue(STREAM_TICK_MS)
if (minutes > 0) {
val amount = minutes * rate
streamedSats += amount
accountViewModel.payV4V(
value = value,
totalSats = amount,
podcastName = podcastName,
episodeName = episodeName,
zappedNote = note,
context = context,
streaming = true,
)
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringRes(R.string.podcast_value_stream),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
// Rate chip — tap to change sats/minute.
Text(
text = stringRes(R.string.podcast_value_stream_rate, rate.toInt()),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { rateMenuOpen = true },
)
DropdownMenu(
expanded = rateMenuOpen,
onDismissRequest = { rateMenuOpen = false },
) {
STREAM_RATE_CHOICES.forEach { choice ->
DropdownMenuItem(
text = { Text(stringRes(R.string.podcast_value_stream_rate, choice.toInt())) },
onClick = {
rate = choice
rateMenuOpen = false
},
)
}
}
}
val status =
if (streamedSats > 0L) {
stringRes(R.string.podcast_value_streamed_total, streamedSats.toInt())
} else {
stringRes(R.string.podcast_value_stream_hint)
}
Text(
text = status,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.grayText,
)
}
Switch(
checked = enabled,
onCheckedChange = { wantOn ->
if (wantOn && !hasInAppWallet) {
// No NWC/CLINK wallet -> we won't auto-stream; tell the user why and stay off.
accountViewModel.toastManager.toast(
R.string.podcast_value_error_title,
R.string.podcast_value_stream_requires_wallet,
)
} else {
enabled = wantOn
if (!wantOn) streamedSats = 0L
}
},
)
}
}
@@ -0,0 +1,111 @@
/*
* 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.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.podcasts.PodcastRemoteContent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* The episode's transcript (from the off-event file referenced by the `transcript` tag), fetched and
* shown in a collapsible, scrollable panel. VTT/SRT scaffolding (the `WEBVTT` header, cue indices,
* and `-->` timing lines) is stripped so it reads as flowing text; plain-text transcripts pass
* through unchanged.
*/
@Composable
fun PodcastTranscriptView(
transcriptUrl: String,
accountViewModel: AccountViewModel,
) {
val transcript by produceState(initialValue = null as String?, transcriptUrl) {
val client = accountViewModel.httpClientBuilder.okHttpClientForPreview(transcriptUrl)
val body = PodcastRemoteContent.fetchText(transcriptUrl, client)
value = body?.let { cleanTranscript(it) }?.takeIf { it.isNotBlank() }
}
val text = transcript ?: return
var expanded by remember(transcriptUrl) { mutableStateOf(false) }
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
CollapsibleHeader(
symbol = MaterialSymbols.Description,
title = stringRes(R.string.podcast_transcript),
expanded = expanded,
onToggle = { expanded = !expanded },
)
if (expanded) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 320.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
}
/**
* Strips VTT/SRT caption scaffolding and joins the remaining caption lines into readable prose.
* Leaves plain-text transcripts effectively untouched. Falls back to the raw body if the cleanup
* removed everything (e.g. an unexpected format).
*/
private fun cleanTranscript(raw: String): String {
val out = StringBuilder()
for (line in raw.lineSequence()) {
val t = line.trim()
if (t.isEmpty()) continue
if (t == "WEBVTT") continue
if (t.startsWith("NOTE ")) continue
if (t.contains("-->")) continue // cue timing line
if (t.toIntOrNull() != null) continue // SRT cue index
out.append(t).append(' ')
}
return out.toString().trim().ifEmpty { raw.trim() }
}
@@ -0,0 +1,137 @@
/*
* 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.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.podcasts.PodcastValue
/**
* Renders a Podcasting-2.0 value-for-value split as a tinted card: a "Value-for-Value" header, a
* one-line hint that zaps to this item are split, and one row per recipient (name/address + its
* share of the split).
*
* This is a **breakdown display only** — there is no dedicated send button. Paying the split is the
* job of the standard zap button: when a podcast note carries a value block, [AccountViewModel.zap]
* detects it and fans the chosen amount out to these recipients (lnaddress shares as real zaps, node
* shares as keysend). Keeping a separate "Send value" button here would just duplicate that action.
*/
@Composable
fun PodcastValueSplits(value: PodcastValue) {
val recipients = value.recipients.filter { it.split > 0 || it.address != null }
if (recipients.isEmpty()) return
val total = value.totalSplit().takeIf { it > 0 } ?: recipients.size
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringRes(R.string.podcast_value_for_value),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
}
Text(
text = stringRes(R.string.podcast_value_zap_split_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
recipients.forEach { recipient ->
val label = recipient.name?.takeIf { it.isNotEmpty() } ?: recipient.address.orEmpty()
val percent = recipient.split * 100 / total
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
recipient.address
?.takeIf { it.isNotEmpty() && it != label }
?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Text(
text = stringRes(R.string.podcast_value_split_percent, percent),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
@@ -328,6 +328,24 @@ class TopNavFilterState(
)
}
private val _podcastRoutes =
combineTransform(
livePeopleListsFlow,
liveInterestFlows,
) { peopleLists, interests ->
checkNotInMainThread()
emit(
listOf(
// Same content-style catalog as kind3GlobalPeopleRoutes, plus "Mine" so the
// podcasts + episodes screens can show only the user's own published shows/episodes.
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow),
peopleLists,
interests,
listOf(muteListFollow),
).flatten().toImmutableList(),
)
}
private val _kind3GlobalPeople =
livePeopleListsFlow.transform { peopleLists ->
checkNotInMainThread()
@@ -408,6 +426,11 @@ class TopNavFilterState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow))
val podcastRoutes =
_podcastRoutes
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow))
fun destroy() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
}
@@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuild
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.service.ClinkDebitPayer
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.V4VPaymentHandler
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -83,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
@@ -158,6 +160,10 @@ import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip92IMeta.imeta
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.podcasts.PodcastBoostagram
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
import com.vitorpamplona.quartz.podcasts.PodcastShow
import com.vitorpamplona.quartz.podcasts.PodcastValue
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -938,6 +944,27 @@ class AccountViewModel(
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
zapType: LnZapEvent.ZapType? = null,
) = launchSigner {
// A podcast note (episode or show) can carry a Podcasting-2.0 value-for-value block. When it
// does, "zapping" it means paying that split — lnaddress recipients go out as real zaps (with
// receipts, which drive this same button's icon/counter), node recipients go out as keysend.
// This makes the standard zap button the single payment action for V4V content.
val v4v =
(note.event as? PodcastEpisode)?.episodeValue()
?: (note.event as? PodcastShow)?.showValue()
if (v4v != null && v4v.recipients.any { it.split > 0 && !it.address.isNullOrBlank() }) {
executeV4V(
value = v4v,
totalMilliSats = amountInMillisats,
podcastName = (note.event as? PodcastShow)?.showTitle(),
episodeName = (note.event as? PodcastEpisode)?.episodeTitle(),
zappedNote = note,
context = context,
streaming = false,
onProgress = onProgress,
)
return@launchSigner
}
val requestedType = zapType ?: defaultZapType()
// Zaps on private rumors are forced to PRIVATE so the sender and
@@ -965,6 +992,92 @@ class AccountViewModel(
)
}
/**
* Executes a Podcasting-2.0 value-for-value split for [totalSats] sats: pays every recipient in
* the show/episode's [PodcastValue] block their weighted share (lnaddress via LNURL-pay, node via
* NWC keysend with the boostagram TLV).
*
* [streaming] marks this as a per-minute streaming payment rather than a one-off boost: the
* boostagram action becomes "stream" and errors are swallowed instead of toasted — a streaming
* session fires once a minute and we don't want per-minute toast spam. One-off boosts surface
* errors on [toastManager]. The external-wallet intent fallback is skipped while [streaming]
* (you can't auto-fire a wallet app every minute); streaming is gated to NWC/CLINK callers.
*/
fun payV4V(
value: PodcastValue,
totalSats: Long,
podcastName: String?,
episodeName: String?,
zappedNote: Note?,
context: Context,
streaming: Boolean = false,
onProgress: (Float) -> Unit = {},
) = launchSigner {
executeV4V(
value = value,
totalMilliSats = totalSats * 1000,
podcastName = podcastName,
episodeName = episodeName,
zappedNote = zappedNote,
context = context,
streaming = streaming,
onProgress = onProgress,
)
}
/**
* Shared V4V execution used by both [payV4V] and the V4V reroute inside [zap]. Must be called
* from within a [launchSigner] block (it does signing). [streaming] = true marks per-minute
* payments: errors are swallowed (no per-minute toast spam), the external-wallet intent fallback
* is skipped (can't auto-launch a wallet every minute), and lnaddress shares are paid WITHOUT a
* zap request so streaming doesn't publish a receipt every minute. One-off boosts ([streaming] =
* false) pay lnaddress shares as real zaps, producing receipts that feed the zap button's UI.
*/
private suspend fun executeV4V(
value: PodcastValue,
totalMilliSats: Long,
podcastName: String?,
episodeName: String?,
zappedNote: Note?,
context: Context,
streaming: Boolean,
onProgress: (Float) -> Unit,
) {
val boostagram =
PodcastBoostagram(
podcast = podcastName,
episode = episodeName,
action = if (streaming) PodcastBoostagram.ACTION_STREAM else PodcastBoostagram.ACTION_BOOST,
appName = "Amethyst",
valueMsatTotal = totalMilliSats,
senderName = account.userProfile().toBestDisplayName(),
)
V4VPaymentHandler(account).pay(
value = value,
totalMilliSats = totalMilliSats,
boostagram = boostagram,
zappedNote = zappedNote,
context = context,
asZap = !streaming,
zapType = LnZapEvent.ZapType.PUBLIC,
okHttpClient = httpClientBuilder::okHttpClientForMoney,
onError = { title, message ->
if (!streaming) toastManager.toast(title, message)
},
onProgress = onProgress,
onPayInvoicesViaIntent = { invoices ->
if (!streaming) {
invoices.forEach { invoice ->
payViaIntent(invoice, context, onPaid = {}, onError = {
toastManager.toast(stringRes(context, R.string.error_dialog_zap_error), it)
})
}
}
},
)
}
/**
* Fire-and-forget NIP-61 nutzap from the zap picker. Picks a mint the
* recipient accepts (via their kind:10019) that we also have proofs at,
@@ -2254,6 +2367,11 @@ class AccountViewModel(
okHttpClient = httpClientBuilder::okHttpClientForVideo,
mimeType = mimeType,
localContext = localContext,
resolveBlossom = {
Amethyst.instance.blossomResolver
.findServers(it)
?.serverUrl
},
onSuccess = {
Handler(Looper.getMainLooper()).post {
Toast
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastEvent
import kotlinx.coroutines.flow.StateFlow
@Composable
@@ -66,6 +67,7 @@ fun ListOfBookmarkGroupsFeedView(
openOldBookmarks: () -> Unit,
openPinnedNotes: () -> Unit,
openRepositories: () -> Unit,
openPodcasts: () -> Unit,
onOpenItem: (String, BookmarkType) -> Unit,
onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit,
onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit,
@@ -99,6 +101,11 @@ fun ListOfBookmarkGroupsFeedView(
HorizontalDivider(thickness = DividerThickness)
}
item {
PodcastsBookmarkList(defaultBookmarks, openPodcasts)
HorizontalDivider(thickness = DividerThickness)
}
itemsIndexed(
bookmarkGroupFeedState,
key = { _: Int, item: LabeledBookmarkList -> item.identifier },
@@ -249,6 +256,54 @@ fun RepositoriesBookmarkList(
)
}
@Composable
fun PodcastsBookmarkList(
defaultBookmarks: BookmarkListState,
openPodcasts: () -> Unit,
) {
val bookmarkState by defaultBookmarks.bookmarks.collectAsStateWithLifecycle()
// Podcasts live in the same kind:10003 list as everything else, so count the podcast subset.
val podcastCount =
(bookmarkState.public + bookmarkState.private).count { isPodcastEvent(it.event) }
ListItem(
modifier = Modifier.clickable(onClick = openPodcasts),
headlineContent = {
Text(stringRes(R.string.podcast_bookmarks), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
supportingContent = {
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
stringRes(R.string.podcast_bookmarks_explainer),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
}
},
leadingContent = {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
symbol = MaterialSymbols.Podcasts,
contentDescription = stringRes(R.string.bookmark_list_icon_label),
modifier = Size40Modifier,
)
Spacer(StdVertSpacer)
BookmarkMembershipStatusAndNumberDisplay(
modifier = Modifier.align(Alignment.CenterHorizontally),
postBookmarksSize = podcastCount,
articleBookmarksSize = 0,
)
}
},
)
}
@Composable
fun OldBookmarkList(
oldBookmarks: OldBookmarkListState,
@@ -66,6 +66,7 @@ fun ListOfBookmarkGroupsScreen(
openOldBookmarks = { nav.nav(Route.OldBookmarks) },
openPinnedNotes = { nav.nav(Route.PinnedNotes) },
openRepositories = { nav.nav(Route.BookmarkedRepositories) },
openPodcasts = { nav.nav(Route.BookmarkedPodcasts) },
addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) },
openBookmarkGroup = { identifier, bookmarkType ->
nav.nav(Route.BookmarkGroupView(identifier, bookmarkType))
@@ -110,6 +111,7 @@ fun ListOfBookmarkGroupsFeed(
openOldBookmarks: () -> Unit,
openPinnedNotes: () -> Unit,
openRepositories: () -> Unit,
openPodcasts: () -> Unit,
addBookmarkGroup: () -> Unit,
openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit,
renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit,
@@ -159,6 +161,7 @@ fun ListOfBookmarkGroupsFeed(
openOldBookmarks = openOldBookmarks,
openPinnedNotes = openPinnedNotes,
openRepositories = openRepositories,
openPodcasts = openPodcasts,
onOpenItem = openBookmarkGroup,
onRenameItem = renameBookmarkGroup,
onItemDescriptionChange = changeBookmarkGroupDescription,
@@ -0,0 +1,69 @@
/*
* 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.screen.loggedIn.bookmarkgroups.podcasts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.dal.BookmarkPodcastsFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun BookmarkedPodcastsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val podcastsFeedViewModel: BookmarkPodcastsFeedViewModel =
viewModel(
key = "NostrBookmarkPodcastsFeedViewModel",
factory = BookmarkPodcastsFeedViewModel.Factory(accountViewModel.account),
)
val bookmarks by accountViewModel.account.bookmarkState.bookmarks
.collectAsStateWithLifecycle()
LaunchedEffect(bookmarks) {
podcastsFeedViewModel.invalidateData()
}
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
TopBarWithBackButton(stringRes(id = R.string.podcast_bookmarks), nav)
},
accountViewModel = accountViewModel,
) {
RefresheableFeedView(
podcastsFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@@ -0,0 +1,48 @@
/*
* 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.screen.loggedIn.bookmarkgroups.podcasts.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastEvent
/**
* The podcast subset of the user's NIP-51 bookmark list (kind 10003): podcasts (shows and episodes)
* are bookmarked into the same general list as everything else, so this filter pulls just the
* podcast-typed notes ([isPodcastEvent]) back out — both public and private — newest first.
*/
class BookmarkPodcastsFeedFilter(
val account: Account,
) : FeedFilter<Note>() {
override fun feedKey(): String =
account.bookmarkState.bookmarks.value
.hashCode()
.toString()
override fun feed(): List<Note> {
val bookmarks = account.bookmarkState.bookmarks.value
return (bookmarks.public + bookmarks.private)
.filter { isPodcastEvent(it.event) }
.distinct()
.sortedByDescending { it.createdAt() ?: 0L }
}
}
@@ -0,0 +1,39 @@
/*
* 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.screen.loggedIn.bookmarkgroups.podcasts.dal
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
@Stable
class BookmarkPodcastsFeedViewModel(
val account: Account,
) : AndroidFeedViewModel(BookmarkPodcastsFeedFilter(account)) {
class Factory(
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = BookmarkPodcastsFeedViewModel(account) as T
}
}
@@ -126,8 +126,14 @@ fun BottomConsoleSheet(
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f),
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 3.dp,
shadowElevation = 6.dp,
// No elevation. This panel docks flush against the app's bottom navigation bar, so a
// shadowElevation just casts a shadow onto that bar — a seam that breaks the flush,
// background-matched look. The tonalElevation had no visual effect here anyway (the color
// isn't exactly colorScheme.surface, so Material never applies the surfaceTint); the
// shadow was the only thing clashing with the nav bar. The grabber + divider still
// separate the panel from the page above it.
tonalElevation = 0.dp,
shadowElevation = 0.dp,
shape = RoundedCornerShape(bottomStart = 0.dp, bottomEnd = 0.dp),
) {
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.4f).dp
@@ -90,7 +90,12 @@ fun TopControlSheet(
Surface(
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 3.dp,
// No tonal elevation. Material 3 only recolors a Surface whose color is EXACTLY
// colorScheme.surface — it swaps in surfaceColorAtElevation(), which blends surfaceTint
// (= primary, Amethyst's purple) over the surface. On the light theme that near-white +
// purple mix reads as a pink/lilac cast instead of the plain background the sheet should
// have. Keep the drop shadow (shadowElevation) to lift the sheet off the page below it.
tonalElevation = 0.dp,
shadowElevation = 6.dp,
shape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp),
) {
@@ -0,0 +1,143 @@
/*
* 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.screen.loggedIn.podcasts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag
/**
* Renders a NIP-F4 podcast's claimed authors (`kind:10154` `p` tags, each a pubkey + role). The
* claims are unverified — the show can name anyone — so each author is cross-checked against their
* own counter-claim ([AuthoredPodcastsEvent], `kind:10064`): a verified check appears only when that
* author's 10064 actually lists this podcast's pubkey. The 10064 is fetched + observed lazily via
* [observeNoteEvent], so it arrives and flips the badge without extra wiring.
*/
@Composable
fun PodcastAuthors(
podcastPubkey: HexKey,
authors: List<AuthorTag>,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
authors.forEach { author ->
PodcastAuthorRow(podcastPubkey, author, accountViewModel, nav)
}
}
}
@Composable
private fun PodcastAuthorRow(
podcastPubkey: HexKey,
author: AuthorTag,
accountViewModel: AccountViewModel,
nav: INav,
) {
var user by remember(author.pubKey) { mutableStateOf(accountViewModel.getUserIfExists(author.pubKey)) }
if (user == null) {
LaunchedEffect(author.pubKey) {
user = accountViewModel.checkGetOrCreateUser(author.pubKey)
}
}
val authoredNote =
remember(author.pubKey) {
LocalCache.getOrCreateAddressableNote(AuthoredPodcastsEvent.createAddress(author.pubKey))
}
val authored by observeNoteEvent<AuthoredPodcastsEvent>(authoredNote, accountViewModel)
val verified = authored?.authors(podcastPubkey) == true
val loadedUser = user ?: return
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { nav.nav(routeFor(loadedUser)) }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ClickableUserPicture(loadedUser, 28.dp, accountViewModel)
Column(modifier = Modifier.weight(1f)) {
UsernameDisplay(loadedUser, accountViewModel = accountViewModel)
author.role?.let {
Text(
text = roleLabel(it),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.grayText,
)
}
}
if (verified) {
Icon(
symbol = MaterialSymbols.CheckCircle,
contentDescription = stringRes(R.string.podcast_author_verified),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun roleLabel(role: String): String =
when (role) {
AuthorTag.ROLE_HOST -> stringRes(R.string.podcast_role_host)
AuthorTag.ROLE_COHOST -> stringRes(R.string.podcast_role_cohost)
AuthorTag.ROLE_EDITOR -> stringRes(R.string.podcast_role_editor)
else -> role
}
@@ -36,15 +36,18 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.note.types.PodcastEpisodeAudioPlayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
private val PLAYER_SHAPE = Modifier.clip(RoundedCornerShape(12.dp))
@@ -59,15 +62,28 @@ fun PodcastEpisodeListItem(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? PodcastEpisodeEvent ?: return
val noteEvent = note.event ?: return
// Both kind 54 (NIP-F4) and kind 30054 (Podcasting 2.0) episodes implement PodcastEpisode.
val episode = noteEvent as? PodcastEpisode ?: return
val title = remember(noteEvent) { noteEvent.title() }
val description = remember(noteEvent) { noteEvent.description() }
val firstAudio = remember(noteEvent) { noteEvent.audios().firstOrNull() }
val image = remember(noteEvent) { noteEvent.image() }
val title = remember(noteEvent) { episode.episodeTitle() }
val description = remember(noteEvent) { episode.episodeDescription() }
// Prefer audio; fall back to a video source so video-only episodes still play inline.
val media = remember(noteEvent) { episode.episodeAudio().firstOrNull() ?: episode.episodeVideo() }
val image = remember(noteEvent) { episode.episodeImage() }
val season = remember(noteEvent) { episode.episodeSeason() }
val episodeNumber = remember(noteEvent) { episode.episodeNumber() }
val context = LocalContext.current
val dateStr = remember(noteEvent) { timeAgo(noteEvent.createdAt, context, prefix = "") }
val seasonEpisodeLabel =
when {
season != null && episodeNumber != null -> stringRes(R.string.podcast_season_episode, season, episodeNumber)
episodeNumber != null -> stringRes(R.string.podcast_episode_number, episodeNumber)
season != null -> stringRes(R.string.podcast_season, season)
else -> null
}
val subtitle = listOfNotNull(seasonEpisodeLabel, dateStr.takeIf { it.isNotBlank() }).joinToString(" · ")
Column(
modifier =
@@ -77,7 +93,7 @@ fun PodcastEpisodeListItem(
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
dateStr.takeIf { it.isNotBlank() }?.let {
subtitle.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
@@ -107,7 +123,7 @@ fun PodcastEpisodeListItem(
)
}
firstAudio?.let { audio ->
media?.let { audio ->
PodcastEpisodeAudioPlayer(
audio = audio,
note = note,
@@ -117,5 +133,16 @@ fun PodcastEpisodeListItem(
accountViewModel = accountViewModel,
)
}
// Standard engagement row per episode (comment / zap / react) — same as every other note.
// addPadding = false since the row already sits inside this item's horizontal padding.
ReactionsRow(
baseNote = note,
showReactionDetail = true,
addPadding = false,
editState = null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@@ -62,9 +62,10 @@ private fun PodcastEpisodesTopNavFilterBar(
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
// Same content-style catalog as Music/Articles — All Follows, Your Follows, kind3
// Follows, Around Me, Global, people lists, interest sets, mute list.
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
// Same content-style catalog as Music — All Follows, Your Follows, kind3 Follows,
// Around Me, Global, Mine (the user's own published shows/episodes), people lists,
// interest sets, mute list.
val allLists by followListsModel.podcastRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
@@ -44,32 +44,43 @@ import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.types.PodcastCoverCard
import com.vitorpamplona.amethyst.ui.note.types.PodcastPeople
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.podcasts.PodcastShow
/**
* Hero header for a single podcast screen: large cover art, title, websites and the show
* description (rich text + translation, same as a profile's About), followed by the
* "Episodes (N)" section divider that the episode rows hang under.
*
* Spec-neutral: [show] is either a NIP-F4 [PodcastMetadataEvent] (kind 10154) or a Podcasting-2.0
* show (kind 30078, `d=podcast-metadata`), both adapting to the shared [PodcastShow]. The claimed-
* author verification row is NIP-F4 only (its `p`-tag claims + kind:10064 counter-claims), so it's
* shown only when the underlying event is a [PodcastMetadataEvent].
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PodcastHeader(
metadataNote: Note,
metadataEvent: PodcastMetadataEvent?,
show: PodcastShow?,
episodeCount: Int?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val title = remember(metadataEvent) { metadataEvent?.title() }
val image = remember(metadataEvent) { metadataEvent?.image() }
val description = remember(metadataEvent) { metadataEvent?.description() }
val websites = remember(metadataEvent) { metadataEvent?.websites() ?: emptyList() }
val tags = remember(metadataEvent) { metadataEvent?.tags?.toImmutableListOfLists() ?: EmptyTagList }
val title = remember(show) { show?.showTitle() }
val image = remember(show) { show?.showImage() }
val description = remember(show) { show?.showDescription() }
val websites = remember(show) { show?.showWebsites() ?: emptyList() }
val f4 = show as? PodcastMetadataEvent
val claimedAuthors = remember(f4) { f4?.claimedAuthors() ?: emptyList() }
val podcastPubkey = remember(f4) { f4?.pubKey }
val tags = remember(metadataNote) { metadataNote.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
Column(Modifier.fillMaxWidth()) {
PodcastCoverCard(image, metadataNote, accountViewModel)
@@ -122,11 +133,37 @@ fun PodcastHeader(
nav = nav,
)
}
if (claimedAuthors.isNotEmpty() && podcastPubkey != null) {
PodcastAuthors(podcastPubkey, claimedAuthors, accountViewModel, nav)
}
val persons = remember(show) { show?.showPersons() ?: emptyList() }
PodcastPeople(persons, accountViewModel, nav)
}
// Standard engagement row for the show itself (comment / zap / react), like any other
// content detail. Only shown once the show event resolves so it acts on a real note.
if (show != null) {
HorizontalDivider(thickness = DividerThickness)
ReactionsRow(
baseNote = metadataNote,
showReactionDetail = true,
addPadding = true,
editState = null,
accountViewModel = accountViewModel,
nav = nav,
)
PodcastTopSupporters(metadataNote, accountViewModel, nav)
}
// Only render once episodes have actually loaded — avoids flashing "0 episodes"
// under the cover while the relay request is still in flight.
episodeCount?.let { count ->
HorizontalDivider(thickness = DividerThickness)
Text(
text = pluralStringResource(R.plurals.podcast_episode_count, count, count),
style = MaterialTheme.typography.titleMedium,
@@ -21,8 +21,10 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
@@ -53,6 +55,8 @@ import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.types.PodcastBookmarkButton
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.OnePodcastFeedViewModel
@@ -60,8 +64,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodc
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.resolvePodcastShow
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.podcasts.PodcastShow
@Composable
fun PodcastScreen(
@@ -70,10 +81,18 @@ fun PodcastScreen(
nav: INav,
) {
val podcast = remember(pubkey) { LocalCache.checkGetOrCreateUser(pubkey) } ?: return
val metadataNote =
// A show is either NIP-F4 (kind 10154) or Podcasting-2.0 (kind 30078, d=podcast-metadata).
// Resolve both addresses; whichever has an event is this podcast's metadata.
val f4Note =
remember(pubkey) {
LocalCache.getOrCreateAddressableNote(PodcastMetadataEvent.createAddress(pubkey))
}
val p20Note =
remember(pubkey) {
LocalCache.getOrCreateAddressableNote(
Address(AppSpecificDataEvent.KIND, pubkey, Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG),
)
}
val feedViewModel: OnePodcastFeedViewModel =
viewModel(
@@ -81,23 +100,27 @@ fun PodcastScreen(
factory = OnePodcastFeedViewModel.Factory(pubkey, accountViewModel.account),
)
PodcastScreen(podcast, metadataNote, feedViewModel, accountViewModel, nav)
PodcastScreen(podcast, f4Note, p20Note, feedViewModel, accountViewModel, nav)
}
@Composable
fun PodcastScreen(
podcast: User,
metadataNote: Note,
f4Note: Note,
p20Note: Note,
feedViewModel: OnePodcastFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedViewModel)
// Fetches both the show metadata (kind 10154) and every episode (kind 54) authored by
// this podcast's key from its outbox relays.
// Fetches the show metadata (NIP-F4 kind 10154 / Podcasting-2.0 kind 30078) and every episode
// (kind 54 / kind 30054) + trailer authored by this podcast's key from its outbox relays.
OnePodcastFilterAssemblerSubscription(podcast, accountViewModel)
val metadataEvent by observeNoteEvent<PodcastMetadataEvent>(metadataNote, accountViewModel)
val f4Event by observeNoteEvent<PodcastMetadataEvent>(f4Note, accountViewModel)
val p20Event by observeNoteEvent<AppSpecificDataEvent>(p20Note, accountViewModel)
val show: PodcastShow? = remember(f4Event, p20Event) { resolvePodcastShow(f4Event) ?: resolvePodcastShow(p20Event) }
val metadataNote = if (f4Event != null) f4Note else p20Note
DisappearingScaffold(
isInvertedLayout = false,
@@ -105,12 +128,23 @@ fun PodcastScreen(
TopBarExtensibleWithBackButton(
title = {
Text(
text = metadataEvent?.title() ?: stringRes(R.string.route_podcasts),
text = show?.showTitle() ?: stringRes(R.string.route_podcasts),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
},
actions = {
// Only offer these once the show metadata has loaded — there must be a resolved
// event to bookmark / act on. The bookmark button reflects bookmarked state; the
// 3-dot button is the standard NoteCompose options menu, sitting to its right.
if (show != null) {
PodcastBookmarkButton(metadataNote, accountViewModel)
Spacer(Modifier.width(Size10dp))
MoreOptionsButton(metadataNote, accountViewModel = accountViewModel, nav = nav)
Spacer(Modifier.width(Size10dp))
}
},
popBack = nav::popBack,
)
},
@@ -120,7 +154,7 @@ fun PodcastScreen(
SaveableFeedState(feedViewModel.feedState, scrollStateKey = null) { listState ->
PodcastScreenBody(
metadataNote = metadataNote,
metadataEvent = metadataEvent,
show = show,
feedViewModel = feedViewModel,
listState = listState,
accountViewModel = accountViewModel,
@@ -134,7 +168,7 @@ fun PodcastScreen(
@Composable
private fun PodcastScreenBody(
metadataNote: Note,
metadataEvent: PodcastMetadataEvent?,
show: PodcastShow?,
feedViewModel: OnePodcastFeedViewModel,
listState: LazyListState,
accountViewModel: AccountViewModel,
@@ -144,20 +178,20 @@ private fun PodcastScreenBody(
when (val state = feedState) {
is FeedState.Loaded ->
PodcastEpisodesList(metadataNote, metadataEvent, state, listState, accountViewModel, nav)
PodcastEpisodesList(metadataNote, show, state, listState, accountViewModel, nav)
is FeedState.Empty ->
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
PodcastHeaderWithStatus(metadataNote, show, listState, accountViewModel, nav) {
StatusText(stringRes(R.string.podcast_no_episodes))
}
is FeedState.FeedError ->
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
PodcastHeaderWithStatus(metadataNote, show, listState, accountViewModel, nav) {
FeedError(state.errorMessage) { feedViewModel.invalidateData() }
}
is FeedState.Loading ->
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
PodcastHeaderWithStatus(metadataNote, show, listState, accountViewModel, nav) {
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
@@ -168,7 +202,7 @@ private fun PodcastScreenBody(
@Composable
private fun PodcastEpisodesList(
metadataNote: Note,
metadataEvent: PodcastMetadataEvent?,
show: PodcastShow?,
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
@@ -181,15 +215,21 @@ private fun PodcastEpisodesList(
contentPadding = rememberFeedContentPadding(FeedPadding),
) {
item("header") {
PodcastHeader(metadataNote, metadataEvent, items.list.size, accountViewModel, nav)
// The list mixes in trailers; the header count should reflect episodes only.
val episodeCount = items.list.count { it.event !is Podcasting20TrailerEvent }
PodcastHeader(metadataNote, show, episodeCount, accountViewModel, nav)
}
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, _ -> "episode" },
) { index, episode ->
PodcastEpisodeListItem(episode, accountViewModel, nav)
contentType = { _, item -> if (item.event is Podcasting20TrailerEvent) "trailer" else "episode" },
) { index, item ->
if (item.event is Podcasting20TrailerEvent) {
PodcastTrailerListItem(item, accountViewModel, nav)
} else {
PodcastEpisodeListItem(item, accountViewModel, nav)
}
if (index < items.list.lastIndex) {
HorizontalDivider(thickness = DividerThickness)
@@ -201,7 +241,7 @@ private fun PodcastEpisodesList(
@Composable
private fun PodcastHeaderWithStatus(
metadataNote: Note,
metadataEvent: PodcastMetadataEvent?,
show: PodcastShow?,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
@@ -212,7 +252,7 @@ private fun PodcastHeaderWithStatus(
contentPadding = rememberFeedContentPadding(FeedPadding),
) {
item("header") {
PodcastHeader(metadataNote, metadataEvent, null, accountViewModel, nav)
PodcastHeader(metadataNote, show, null, accountViewModel, nav)
}
item("status") { status() }
}
@@ -0,0 +1,188 @@
/*
* 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.screen.loggedIn.podcasts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivityTopZappersAggregator
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.TopZapperEntry
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ZapContribution
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import java.math.BigDecimal
private val Gold = Color(0xFFFFC300)
private val Silver = Color(0xFFB0B7C0)
private val Bronze = Color(0xFFCD7F32)
/**
* "Top Supporters" leaderboard for a podcast show: aggregates the zaps on the show note into a
* sats-ranked list (reusing [LiveActivityTopZappersAggregator], the same engine as the live-stream
* leaderboard), top 3 flagged with gold/silver/bronze medals. Renders nothing until there's at least
* one zap. Matches PodStr's ZapLeaderboard, Nostr-native.
*/
@Composable
fun PodcastTopSupporters(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val zapState by observeNoteZaps(note, accountViewModel)
val entries =
remember(zapState) {
val contributions =
note.zaps.mapNotNull { (_, receiptNote) ->
val receipt = receiptNote?.event as? LnZapEvent ?: return@mapNotNull null
val request = receipt.zapRequest ?: return@mapNotNull null
val sats = receipt.amount()?.toLong() ?: return@mapNotNull null
// Anon/private zaps carry an `anon` tag; collapse them into the shared bucket.
val isAnon = request.tags.any { it.isNotEmpty() && it[0] == "anon" }
ZapContribution(receiptNote.idHex, request.pubKey, isAnon, sats)
}
LiveActivityTopZappersAggregator.aggregate(contributions)
}
if (entries.isEmpty()) return
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = stringRes(R.string.podcast_top_supporters),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
)
entries.forEachIndexed { index, entry ->
SupporterRow(index, entry, accountViewModel, nav)
}
}
}
@Composable
private fun SupporterRow(
index: Int,
entry: TopZapperEntry,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
RankBadge(index)
if (entry.isAnonymous) {
Text(
text = stringRes(R.string.chat_zap_anonymous),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
} else {
LoadUser(entry.bucketKey, accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(user, Size35dp, accountViewModel, onClick = { nav.nav(routeFor(it)) })
UsernameDisplay(user, Modifier.weight(1f), accountViewModel = accountViewModel)
} else {
Text(
text = "",
modifier = Modifier.weight(1f),
)
}
}
}
ZapIcon(Size16Modifier, BitcoinOrange)
Text(
text = showAmountInteger(BigDecimal.valueOf(entry.totalSats)),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
)
}
}
/** Gold/silver/bronze medal for the top 3, plain "#N" for the rest. */
@Composable
private fun RankBadge(index: Int) {
val medal =
when (index) {
0 -> Gold
1 -> Silver
2 -> Bronze
else -> null
}
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
) {
if (medal != null) {
Icon(
symbol = MaterialSymbols.MilitaryTech,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = medal,
)
} else {
Text(
text = "${index + 1}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
}
}
@@ -0,0 +1,133 @@
/*
* 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.screen.loggedIn.podcasts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.note.types.PodcastEpisodeAudioPlayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.podcasts.PodcastAudio
private val PLAYER_SHAPE = Modifier.clip(RoundedCornerShape(12.dp))
/**
* A Podcasting-2.0 trailer (kind 30055) as a compact row, used on a podcast's show page and as
* the inline renderer in [com.vitorpamplona.amethyst.ui.note.NoteCompose]. A "Trailer" badge (with
* the season, when present) distinguishes it from episode rows; the media plays through the same
* player as episodes via the spec-neutral [PodcastAudio].
*/
@Composable
fun PodcastTrailerListItem(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? Podcasting20TrailerEvent ?: return
val title = remember(noteEvent) { noteEvent.title() }
val season = remember(noteEvent) { noteEvent.season() }
val media =
remember(noteEvent) {
noteEvent.url()?.let { PodcastAudio(it, noteEvent.mimeType()) }
}
val context = LocalContext.current
val dateStr = remember(noteEvent) { timeAgo(noteEvent.createdAt, context, prefix = "") }
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringRes(R.string.podcast_trailer),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
season?.let {
Text(
text = stringRes(R.string.podcast_trailer_season, it),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
dateStr.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
}
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
media?.let { audio ->
PodcastEpisodeAudioPlayer(
audio = audio,
note = note,
title = title,
image = null,
borderModifier = PLAYER_SHAPE,
accountViewModel = accountViewModel,
)
}
}
}
@@ -20,11 +20,18 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
@@ -33,12 +40,14 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Composable
@@ -82,6 +91,21 @@ fun PodcastsScreen(
}
}
},
floatingButton = {
FabBottomBarPadded(nav) {
FloatingActionButton(
onClick = { nav.nav(Route.PodcastAuthoring) },
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
symbol = MaterialSymbols.Mic,
contentDescription = stringRes(R.string.podcast_your_podcast),
tint = Color.White,
)
}
}
},
accountViewModel = accountViewModel,
) {
RefresheableBox(podcastsFeedContentState, true) {
@@ -62,7 +62,7 @@ private fun PodcastsTopNavFilterBar(
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
val allLists by followListsModel.podcastRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
@@ -0,0 +1,212 @@
/*
* 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.screen.loggedIn.podcasts.authoring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.CoverImagePicker
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadInProgressBanner
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.persistentListOf
/**
* Editor for the creator's Podcasting-2.0 show metadata (`kind:30078`, `d="podcast-metadata"`).
* There is one show per account, so this is always create-or-edit of the same event. Cover upload
* plus the channel fields; explicit / complete / locked as switches; episodic vs serial as a toggle.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditPodcastShowScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val vm: EditPodcastShowViewModel = viewModel()
val context = LocalContext.current
LaunchedEffect(accountViewModel) { vm.init(accountViewModel) }
StrippingFailureDialog(vm.strippingFailureConfirmation)
var wantsToPickCover by remember { mutableStateOf(false) }
if (wantsToPickCover) {
GallerySelectSingle(
onImageUri = { picked ->
wantsToPickCover = false
vm.setPickedCover(if (picked != null) persistentListOf(picked) else persistentListOf())
},
)
}
val isBusy = vm.isSending.value
LaunchedEffect(vm) { vm.completionEvents.collect { nav.popBack() } }
Scaffold(
topBar = {
SendingTopBar(
titleRes = R.string.podcast_edit_show,
onCancel = { nav.popBack() },
isActive = { vm.isValid() && !isBusy },
onPost = {
if (!vm.isValid() || isBusy) return@SendingTopBar
vm.saveAndPublish(context, accountViewModel)
},
)
},
) { pad ->
Column(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (isBusy) UploadInProgressBanner(R.string.podcast_publishing_banner)
CoverImagePicker(
cover = vm.coverMedia.value,
existingUrl = vm.coverUrl.value,
onPick = { wantsToPickCover = true },
onDelete = { vm.clearPickedCover() },
accountViewModel = accountViewModel,
enabled = !isBusy,
ctaRes = R.string.podcast_show_cover_cta,
hintRes = R.string.podcast_show_cover_hint,
)
Field(vm.title, R.string.podcast_show_title_label, R.string.podcast_show_title_placeholder, isError = vm.title.value.isBlank())
OutlinedTextField(
value = vm.description.value,
onValueChange = { vm.description.value = it },
label = { Text(stringRes(R.string.podcast_show_description_label)) },
modifier = Modifier.fillMaxWidth(),
minLines = 4,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
)
Field(vm.author, R.string.podcast_show_author_label, null, capitalization = KeyboardCapitalization.Words)
Field(vm.email, R.string.podcast_show_email_label, null, keyboardType = KeyboardType.Email)
Field(vm.website, R.string.podcast_show_website_label, null, keyboardType = KeyboardType.Uri)
Field(vm.categories, R.string.podcast_show_categories_label, R.string.podcast_show_categories_placeholder)
Field(vm.funding, R.string.podcast_show_funding_label, R.string.podcast_show_funding_placeholder, keyboardType = KeyboardType.Uri)
Field(vm.language, R.string.podcast_show_language_label, R.string.podcast_show_language_placeholder)
Field(vm.copyright, R.string.podcast_show_copyright_label, null)
// Episodic vs serial.
Text(
text = stringRes(R.string.podcast_show_type_label),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(top = 4.dp),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = vm.type.value == "episodic" || vm.type.value.isBlank(),
onClick = { vm.type.value = "episodic" },
label = { Text(stringRes(R.string.podcast_show_type_episodic)) },
)
FilterChip(
selected = vm.type.value == "serial",
onClick = { vm.type.value = "serial" },
label = { Text(stringRes(R.string.podcast_show_type_serial)) },
)
}
SwitchRow(stringRes(R.string.podcast_show_explicit), vm.explicit.value) { vm.explicit.value = it }
SwitchRow(stringRes(R.string.podcast_show_complete), vm.complete.value) { vm.complete.value = it }
SwitchRow(stringRes(R.string.podcast_show_locked), vm.locked.value) { vm.locked.value = it }
V4VSplitEditor(vm.splitEditor, accountViewModel)
}
}
}
@Composable
private fun Field(
state: androidx.compose.runtime.MutableState<String>,
labelRes: Int,
placeholderRes: Int?,
isError: Boolean = false,
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
keyboardType: KeyboardType = KeyboardType.Text,
) {
OutlinedTextField(
value = state.value,
onValueChange = { state.value = it },
label = { Text(stringRes(labelRes)) },
placeholder = placeholderRes?.let { { Text(stringRes(it)) } },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = isError,
keyboardOptions = KeyboardOptions(capitalization = capitalization, keyboardType = keyboardType),
)
}
@Composable
private fun SwitchRow(
label: String,
checked: Boolean,
onChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = onChange)
}
}
@@ -0,0 +1,247 @@
/*
* 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.screen.loggedIn.podcasts.authoring
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.podcasts.V4VSplitEditorState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.withContext
/**
* Editor for a creator's Podcasting-2.0 show metadata — the single replaceable `kind:30078`
* (`d="podcast-metadata"`) event whose JSON body holds the channel-level fields. There is one per
* account, so this is always an edit-or-create of the same address; saving replaces it in place.
*
* Mirrors the profile-metadata editor: text fields + a cover upload, no audio. Any value-for-value
* block already on the event is preserved across save (the splits editor is separate).
*/
class EditPodcastShowViewModel : ViewModel() {
private lateinit var account: Account
val title = mutableStateOf("")
val description = mutableStateOf("")
val author = mutableStateOf("")
val email = mutableStateOf("")
val coverUrl = mutableStateOf("")
val website = mutableStateOf("")
val language = mutableStateOf("")
val categories = mutableStateOf("")
val funding = mutableStateOf("")
val copyright = mutableStateOf("")
/** "episodic" or "serial" (Podcasting 2.0), or blank for unset. */
val type = mutableStateOf("")
val explicit = mutableStateOf(false)
val complete = mutableStateOf(false)
val locked = mutableStateOf(false)
val isSending = mutableStateOf(false)
private val _completionEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val completionEvents: SharedFlow<Unit> = _completionEvents.asSharedFlow()
val coverMedia = mutableStateOf<MultiOrchestrator?>(null)
val strippingFailureConfirmation = SuspendableConfirmation()
val selectedServer = mutableStateOf<ServerName?>(null)
val mediaQualitySlider = mutableStateOf(1)
val stripMetadata = mutableStateOf(true)
/** Editable value-for-value split for the show. */
val splitEditor = V4VSplitEditorState()
/** Fields the editor doesn't surface but must not drop on save. */
private var preservedGuid: String? = null
private var hasExisting = false
fun init(accountViewModel: AccountViewModel) {
if (::account.isInitialized) return
this.account = accountViewModel.account
this.selectedServer.value = account.settings.defaultFileServer
this.stripMetadata.value = account.settings.stripLocationOnUpload
val address = Address(AppSpecificDataEvent.KIND, account.userProfile().pubkeyHex, Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)
val existing = (LocalCache.addressables.get(address)?.event as? AppSpecificDataEvent)?.let { Podcasting20PodcastMetadata.parse(it) }
if (existing != null) {
hasExisting = true
preservedGuid = existing.guid()
splitEditor.load(existing.showValue())
title.value = existing.showTitle().orEmpty()
description.value = existing.showDescription().orEmpty()
author.value = existing.showAuthor().orEmpty()
email.value = existing.email().orEmpty()
coverUrl.value = existing.showImage().orEmpty()
website.value = existing.showWebsites().firstOrNull().orEmpty()
language.value = existing.language().orEmpty()
categories.value = existing.showCategories().joinToString(", ")
funding.value = existing.showFundingUrls().joinToString(", ")
copyright.value = existing.showCopyright().orEmpty()
type.value = existing.type().orEmpty()
explicit.value = existing.showIsExplicit()
complete.value = existing.showIsComplete()
locked.value = existing.isLocked()
}
}
fun setPickedCover(uris: ImmutableList<SelectedMedia>) {
coverMedia.value = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null
}
fun clearPickedCover() {
coverMedia.value = null
coverUrl.value = ""
}
fun isValid(): Boolean = title.value.isNotBlank()
fun saveAndPublish(
context: Context,
accountViewModel: AccountViewModel,
) {
if (isSending.value) return
val coverOrch = coverMedia.value
val server = selectedServer.value
if (coverOrch != null && server == null) {
accountViewModel.toastManager.toast(
"No upload server selected",
"Pick a media server in settings before uploading.",
)
return
}
val snapshot =
Snapshot(
content =
Podcasting20PodcastMetadata.Content(
title = title.value.trim(),
description = description.value.trim().ifBlank { null },
author = author.value.trim().ifBlank { null },
email = email.value.trim().ifBlank { null },
image = coverUrl.value.trim().ifBlank { null },
language = language.value.trim().ifBlank { null },
categories = PodcastComposerMedia.parseCsv(categories.value),
explicit = explicit.value.takeIf { it },
website = website.value.trim().ifBlank { null },
copyright = copyright.value.trim().ifBlank { null },
funding = PodcastComposerMedia.parseCsv(funding.value),
locked = locked.value.takeIf { it },
type = type.value.trim().ifBlank { null },
complete = complete.value.takeIf { it },
guid = preservedGuid,
value = splitEditor.toPodcastValue(),
),
coverOrchestrator = coverOrch,
server = server,
quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value),
stripMetadata = stripMetadata.value,
appContext = context.applicationContext,
)
isSending.value = true
accountViewModel.launchSigner {
try {
val newCoverUrl =
snapshot.coverOrchestrator?.let {
PodcastComposerMedia.upload(
orchestrator = it,
kind = "cover",
account = account,
server = snapshot.server!!,
quality = snapshot.quality,
stripMetadata = snapshot.stripMetadata,
alt = snapshot.content.title,
context = snapshot.appContext,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
}
val finalContent = newCoverUrl?.let { snapshot.content.copyWithImage(it) } ?: snapshot.content
account.signAndComputeBroadcast(Podcasting20PodcastMetadata.build(finalContent))
if (snapshot.coverOrchestrator != null) {
account.settings.changeDefaultFileServer(snapshot.server!!)
account.settings.changeStripLocationOnUpload(snapshot.stripMetadata)
}
withContext(Dispatchers.Main.immediate) {
coverMedia.value = null
if (newCoverUrl != null) coverUrl.value = newCoverUrl
}
_completionEvents.tryEmit(Unit)
} catch (t: Throwable) {
accountViewModel.toastManager.toast(
"Failed to save podcast",
t.message ?: t.javaClass.simpleName,
)
} finally {
withContext(Dispatchers.Main.immediate) { isSending.value = false }
}
}
}
private class Snapshot(
val content: Podcasting20PodcastMetadata.Content,
val coverOrchestrator: MultiOrchestrator?,
val server: ServerName?,
val quality: com.vitorpamplona.amethyst.service.uploads.CompressorQuality,
val stripMetadata: Boolean,
val appContext: Context,
)
}
/** Copies a metadata content with a new image URL (Content has no copy() — it's a plain class). */
private fun Podcasting20PodcastMetadata.Content.copyWithImage(image: String): Podcasting20PodcastMetadata.Content =
Podcasting20PodcastMetadata.Content(
title = title,
description = description,
author = author,
email = email,
image = image,
language = language,
categories = categories,
explicit = explicit,
website = website,
copyright = copyright,
funding = funding,
locked = locked,
type = type,
complete = complete,
guid = guid,
value = value,
)
@@ -0,0 +1,374 @@
/*
* 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.screen.loggedIn.podcasts.authoring
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.CoverImagePicker
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadInProgressBanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadPlaceholder
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.persistentListOf
/**
* Composer for a Podcasting-2.0 episode. Cover art + audio file upload at the top (or paste URLs),
* the core fields (title, summary, duration), and a collapsible "More details" section for the
* Podcasting-2.0 extras (season/number, video, transcript, chapters, topics). Create + edit + delete.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewPodcastEpisodeScreen(
editDTag: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
val vm: NewPodcastEpisodeViewModel = viewModel()
val context = LocalContext.current
LaunchedEffect(accountViewModel) { vm.init(accountViewModel, editDTag) }
StrippingFailureDialog(vm.strippingFailureConfirmation)
var wantsToPickCover by remember { mutableStateOf(false) }
if (wantsToPickCover) {
GallerySelectSingle(
onImageUri = { picked ->
wantsToPickCover = false
vm.setPickedCover(if (picked != null) persistentListOf(picked) else persistentListOf())
},
)
}
var wantsToPickAudio by remember { mutableStateOf(false) }
if (wantsToPickAudio) {
AudioFileSelect { picked ->
wantsToPickAudio = false
vm.setPickedAudio(context, picked)
}
}
val isBusy = vm.isSending.value
LaunchedEffect(vm) { vm.completionEvents.collect { nav.popBack() } }
Scaffold(
topBar = {
SendingTopBar(
titleRes = if (vm.isEditing) R.string.podcast_edit_episode else R.string.podcast_new_episode,
onCancel = { nav.popBack() },
isActive = { vm.isValid() && !isBusy },
onPost = {
if (!vm.isValid() || isBusy) return@SendingTopBar
vm.saveAndPublish(context, accountViewModel)
},
)
},
) { pad ->
Column(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (isBusy) UploadInProgressBanner(R.string.podcast_publishing_banner)
CoverImagePicker(
cover = vm.coverMedia.value,
existingUrl = vm.coverUrl.value,
onPick = { wantsToPickCover = true },
onDelete = { vm.clearPickedCover() },
accountViewModel = accountViewModel,
enabled = !isBusy,
ctaRes = R.string.podcast_cover_upload_cta,
hintRes = R.string.podcast_cover_upload_hint,
)
AudioFilePickerRow(
pickedName = vm.pickedAudioName.value,
onPick = { wantsToPickAudio = true },
onClear = { vm.clearPickedAudio() },
enabled = !isBusy,
)
OutlinedTextField(
value = vm.title.value,
onValueChange = { vm.title.value = it },
label = { Text(stringRes(R.string.podcast_episode_title_label)) },
placeholder = { Text(stringRes(R.string.podcast_episode_title_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
isError = vm.title.value.isBlank(),
)
OutlinedTextField(
value = vm.description.value,
onValueChange = { vm.description.value = it },
label = { Text(stringRes(R.string.podcast_episode_summary_label)) },
modifier = Modifier.fillMaxWidth(),
minLines = 4,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
)
OutlinedTextField(
value = vm.durationSeconds.value,
onValueChange = { input -> vm.durationSeconds.value = input.filter { it.isDigit() } },
label = { Text(stringRes(R.string.podcast_episode_duration_label)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
var showMore by rememberSaveable { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth().clickable { showMore = !showMore }.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(R.string.podcast_episode_more_details),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Icon(
symbol = if (showMore) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Modifier.size(22.dp),
)
}
if (showMore) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = vm.season.value,
onValueChange = { input -> vm.season.value = input.filter { it.isDigit() } },
label = { Text(stringRes(R.string.podcast_episode_season_label)) },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
value = vm.episodeNumber.value,
onValueChange = { input -> vm.episodeNumber.value = input.filter { it.isDigit() } },
label = { Text(stringRes(R.string.podcast_episode_number_label)) },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
}
UrlField(vm.videoUrl, R.string.podcast_episode_video_label)
UrlField(vm.transcriptUrl, R.string.podcast_episode_transcript_label)
UrlField(vm.chaptersUrl, R.string.podcast_episode_chapters_label)
OutlinedTextField(
value = vm.topics.value,
onValueChange = { vm.topics.value = it },
label = { Text(stringRes(R.string.podcast_episode_topics_label)) },
placeholder = { Text(stringRes(R.string.podcast_episode_topics_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = vm.audioUrl.value,
onValueChange = { vm.audioUrl.value = it },
label = { Text(stringRes(R.string.podcast_episode_audio_url_label)) },
placeholder = { Text(stringRes(R.string.podcast_episode_audio_url_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
// Episode-level V4V override; leave empty to inherit the show's split.
V4VSplitEditor(vm.splitEditor, accountViewModel)
}
if (vm.isEditing) {
HorizontalDivider()
DeleteEpisodeRow(vm = vm, onDeleted = { nav.popBack() }, accountViewModel = accountViewModel)
}
}
}
}
@Composable
private fun UrlField(
state: androidx.compose.runtime.MutableState<String>,
labelRes: Int,
) {
OutlinedTextField(
value = state.value,
onValueChange = { state.value = it },
label = { Text(stringRes(labelRes)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
}
@Composable
private fun AudioFilePickerRow(
pickedName: String?,
onPick: () -> Unit,
onClear: () -> Unit,
enabled: Boolean,
) {
if (pickedName != null) {
Row(
modifier =
Modifier
.fillMaxWidth()
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(12.dp))
.let { if (enabled) it.clickable(onClick = onPick) else it }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.MusicNote,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text(text = pickedName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(
text = stringRes(R.string.podcast_episode_audio_picked),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (enabled) {
TextButton(onClick = onClear) { Text(stringRes(R.string.cancel)) }
}
}
} else {
UploadPlaceholder(
iconSymbol = MaterialSymbols.MusicNote,
ctaRes = R.string.podcast_episode_audio_upload_cta,
hintRes = R.string.podcast_episode_audio_upload_hint,
onClick = onPick,
aspectRatio = null,
enabled = enabled,
)
}
}
// Single audio file via OpenDocument restricted to audio MIME types.
@Composable
private fun AudioFileSelect(onAudioPicked: (SelectedMedia?) -> Unit) {
val resolver = LocalContext.current.contentResolver
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri: Uri? -> onAudioPicked(uri?.let { SelectedMedia(it, resolver.getType(it)) }) },
)
LaunchedEffect(Unit) { launcher.launch(arrayOf("audio/*")) }
}
@Composable
private fun DeleteEpisodeRow(
vm: NewPodcastEpisodeViewModel,
onDeleted: () -> Unit,
accountViewModel: AccountViewModel,
) {
var confirming by rememberSaveable { mutableStateOf(false) }
OutlinedButton(
onClick = { confirming = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(text = stringRes(R.string.podcast_episode_delete))
}
if (confirming) {
AlertDialog(
onDismissRequest = { confirming = false },
title = { Text(stringRes(R.string.podcast_episode_delete)) },
text = { Text(stringRes(R.string.podcast_episode_delete_confirm)) },
confirmButton = {
TextButton(onClick = {
confirming = false
accountViewModel.launchSigner { if (vm.deleteLoaded()) onDeleted() }
}) {
Text(text = stringRes(R.string.podcast_episode_delete), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { confirming = false }) { Text(stringRes(R.string.cancel)) }
},
)
}
}
@@ -0,0 +1,335 @@
/*
* 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.screen.loggedIn.podcasts.authoring
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.podcasts.V4VSplitEditorState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import com.vitorpamplona.quartz.podcasts.PodcastValue
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Composer for a Podcasting-2.0 episode (`kind:30054`, addressable). Closely mirrors the music-track
* composer: the user picks a cover image and an audio file, Save uploads them through Blossom/NIP-96,
* then publishes the addressable event with the returned URLs. Power users can paste raw URLs instead.
*
* Edit mode (`editDTag` resolves from LocalCache) re-publishes under the same `d` tag so it replaces
* the prior version in place; the original `pubdate` and any value-for-value block are preserved.
*/
class NewPodcastEpisodeViewModel : ViewModel() {
private lateinit var account: Account
val title = mutableStateOf("")
val description = mutableStateOf("")
val audioUrl = mutableStateOf("")
val coverUrl = mutableStateOf("")
val durationSeconds = mutableStateOf("")
val episodeNumber = mutableStateOf("")
val season = mutableStateOf("")
val videoUrl = mutableStateOf("")
val transcriptUrl = mutableStateOf("")
val chaptersUrl = mutableStateOf("")
val topics = mutableStateOf("")
val isSending = mutableStateOf(false)
private val _completionEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val completionEvents: SharedFlow<Unit> = _completionEvents.asSharedFlow()
val coverMedia = mutableStateOf<MultiOrchestrator?>(null)
val audioMedia = mutableStateOf<MultiOrchestrator?>(null)
val pickedAudioName = mutableStateOf<String?>(null)
val strippingFailureConfirmation = SuspendableConfirmation()
val selectedServer = mutableStateOf<ServerName?>(null)
val mediaQualitySlider = mutableStateOf(1)
val stripMetadata = mutableStateOf(true)
private var dTag: String? = null
private var loadedEvent: Podcasting20EpisodeEvent? = null
/** Editable value-for-value split for this episode (overrides the show's split). */
val splitEditor = V4VSplitEditorState()
/** Carried across an edit so we don't drop the original publish date on save. */
private var preservedPubDate: String? = null
val isEditing: Boolean
get() = loadedEvent != null
fun init(
accountViewModel: AccountViewModel,
editDTag: String?,
) {
if (::account.isInitialized) return
this.account = accountViewModel.account
this.selectedServer.value = account.settings.defaultFileServer
this.stripMetadata.value = account.settings.stripLocationOnUpload
if (editDTag != null) {
val address = Address(Podcasting20EpisodeEvent.KIND, account.userProfile().pubkeyHex, editDTag)
(LocalCache.addressables.get(address)?.event as? Podcasting20EpisodeEvent)?.let { existing ->
dTag = editDTag
loadedEvent = existing
preservedPubDate = existing.pubDate()
splitEditor.load(existing.value())
title.value = existing.title().orEmpty()
description.value = existing.description().orEmpty()
audioUrl.value =
existing
.audios()
.firstOrNull()
?.url
.orEmpty()
coverUrl.value = existing.image().orEmpty()
durationSeconds.value = existing.durationInSeconds()?.toString().orEmpty()
episodeNumber.value = existing.number()?.toString().orEmpty()
season.value = existing.season()?.toString().orEmpty()
videoUrl.value = existing.video()?.url.orEmpty()
transcriptUrl.value = existing.transcriptUrl().orEmpty()
chaptersUrl.value = existing.chaptersUrl().orEmpty()
topics.value = existing.topics().joinToString(", ")
}
}
}
fun setPickedCover(uris: ImmutableList<SelectedMedia>) {
coverMedia.value = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null
}
fun clearPickedCover() {
coverMedia.value = null
coverUrl.value = ""
}
fun setPickedAudio(
context: Context,
uri: SelectedMedia?,
) {
if (uri == null) {
audioMedia.value = null
pickedAudioName.value = null
return
}
audioMedia.value = MultiOrchestrator(persistentListOf(uri))
pickedAudioName.value = uri.uri.lastPathSegment?.substringAfterLast('/')
val appContext = context.applicationContext
viewModelScope.launch(Dispatchers.IO) {
val probed = PodcastComposerMedia.probeAudio(appContext, uri.uri) ?: return@launch
withContext(Dispatchers.Main.immediate) {
probed.durationSeconds?.let { durationSeconds.value = it.toString() }
if (title.value.isBlank()) probed.title?.let { title.value = it }
}
}
}
fun clearPickedAudio() {
audioMedia.value = null
pickedAudioName.value = null
}
/** Valid with a title and a resolvable audio source (picked file or a pasted URL). */
fun isValid(): Boolean = title.value.isNotBlank() && (audioMedia.value != null || audioUrl.value.isNotBlank())
fun saveAndPublish(
context: Context,
accountViewModel: AccountViewModel,
) {
if (isSending.value) return
val server = selectedServer.value
if (server == null) {
accountViewModel.toastManager.toast(
"No upload server selected",
"Pick a media server in settings before uploading.",
)
return
}
val snapshot =
Snapshot(
title = title.value.trim(),
description = description.value.trim().ifBlank { null },
durationSeconds = durationSeconds.value.trim().toLongOrNull(),
episodeNumber = episodeNumber.value.trim().toIntOrNull(),
season = season.value.trim().toIntOrNull(),
videoUrl = videoUrl.value.trim().ifBlank { null },
transcriptUrl = transcriptUrl.value.trim().ifBlank { null },
chaptersUrl = chaptersUrl.value.trim().ifBlank { null },
topics = PodcastComposerMedia.parseCsv(topics.value),
value = splitEditor.toPodcastValue(),
coverOrchestrator = coverMedia.value,
audioOrchestrator = audioMedia.value,
existingCoverUrl = coverUrl.value.trim().ifBlank { null },
existingAudioUrl = audioUrl.value.trim(),
server = server,
quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value),
stripMetadata = stripMetadata.value,
appContext = context.applicationContext,
)
isSending.value = true
accountViewModel.launchSigner {
try {
val (newCoverUrl, newAudioUrl) = performParallelUploads(snapshot)
val finalCoverUrl = newCoverUrl ?: snapshot.existingCoverUrl
val finalAudioUrl = newAudioUrl ?: snapshot.existingAudioUrl
if (finalAudioUrl.isBlank()) {
accountViewModel.toastManager.toast(
"Audio upload failed",
"No audio URL ended up available for the published episode.",
)
return@launchSigner
}
publishEpisode(snapshot, finalCoverUrl, finalAudioUrl)
account.settings.changeDefaultFileServer(snapshot.server)
account.settings.changeStripLocationOnUpload(snapshot.stripMetadata)
withContext(Dispatchers.Main.immediate) {
coverMedia.value = null
audioMedia.value = null
pickedAudioName.value = null
if (newCoverUrl != null) coverUrl.value = newCoverUrl
if (newAudioUrl != null) audioUrl.value = newAudioUrl
}
_completionEvents.tryEmit(Unit)
} catch (t: Throwable) {
accountViewModel.toastManager.toast(
"Failed to publish episode",
t.message ?: t.javaClass.simpleName,
)
} finally {
withContext(Dispatchers.Main.immediate) { isSending.value = false }
}
}
}
private class Snapshot(
val title: String,
val description: String?,
val durationSeconds: Long?,
val episodeNumber: Int?,
val season: Int?,
val videoUrl: String?,
val transcriptUrl: String?,
val chaptersUrl: String?,
val topics: List<String>,
val value: PodcastValue?,
val coverOrchestrator: MultiOrchestrator?,
val audioOrchestrator: MultiOrchestrator?,
val existingCoverUrl: String?,
val existingAudioUrl: String,
val server: ServerName,
val quality: com.vitorpamplona.amethyst.service.uploads.CompressorQuality,
val stripMetadata: Boolean,
val appContext: Context,
)
private suspend fun performParallelUploads(snapshot: Snapshot): Pair<String?, String?> =
coroutineScope {
val deferreds =
listOf(
async { snapshot.coverOrchestrator?.let { uploadOne(it, "cover", snapshot) } },
async { snapshot.audioOrchestrator?.let { uploadOne(it, "audio", snapshot) } },
)
val results = deferreds.awaitAll()
results[0] to results[1]
}
private suspend fun uploadOne(
orchestrator: MultiOrchestrator,
kind: String,
snapshot: Snapshot,
): String =
PodcastComposerMedia.upload(
orchestrator = orchestrator,
kind = kind,
account = account,
server = snapshot.server,
quality = snapshot.quality,
stripMetadata = snapshot.stripMetadata,
alt = snapshot.title.ifBlank { null },
context = snapshot.appContext,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
private suspend fun publishEpisode(
snapshot: Snapshot,
coverUrl: String?,
audioUrl: String,
) {
// Audio/video MIME isn't tracked once uploaded (the URL is enough; the player sniffs the
// type). The summary lives in the `description` tag; `content` stays empty so the episode
// renderer doesn't show the same text twice (it renders the tag and the markdown body apart).
val template =
Podcasting20EpisodeEvent.build(
dTag = dTag ?: PodcastComposerMedia.generateDTag("episode"),
title = snapshot.title,
audios = listOf(PodcastAudio(audioUrl, null)),
pubdate = preservedPubDate ?: PodcastComposerMedia.rfc2822Now(),
description = snapshot.description,
image = coverUrl?.ifBlank { null },
durationInSeconds = snapshot.durationSeconds,
video = snapshot.videoUrl?.let { PodcastAudio(it, null) },
episodeNumber = snapshot.episodeNumber,
season = snapshot.season,
transcriptUrl = snapshot.transcriptUrl,
chaptersUrl = snapshot.chaptersUrl,
value = snapshot.value,
topics = snapshot.topics,
)
account.signAndComputeBroadcast(template)
}
suspend fun deleteLoaded(): Boolean {
val target = loadedEvent ?: return false
val note = LocalCache.getOrCreateAddressableNote(target.address())
account.delete(note)
return true
}
}
@@ -0,0 +1,204 @@
/*
* 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.screen.loggedIn.podcasts.authoring
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadInProgressBanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadPlaceholder
import com.vitorpamplona.amethyst.ui.stringRes
/** Composer for a Podcasting-2.0 trailer (`kind:30055`): title, a short audio/video clip, and season. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewPodcastTrailerScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val vm: NewPodcastTrailerViewModel = viewModel()
val context = LocalContext.current
LaunchedEffect(accountViewModel) { vm.init(accountViewModel) }
StrippingFailureDialog(vm.strippingFailureConfirmation)
var wantsToPick by remember { mutableStateOf(false) }
if (wantsToPick) {
MediaFileSelect { picked ->
wantsToPick = false
vm.setPickedMedia(picked)
}
}
val isBusy = vm.isSending.value
LaunchedEffect(vm) { vm.completionEvents.collect { nav.popBack() } }
Scaffold(
topBar = {
SendingTopBar(
titleRes = R.string.podcast_new_trailer,
onCancel = { nav.popBack() },
isActive = { vm.isValid() && !isBusy },
onPost = {
if (!vm.isValid() || isBusy) return@SendingTopBar
vm.saveAndPublish(context, accountViewModel)
},
)
},
) { pad ->
Column(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (isBusy) UploadInProgressBanner(R.string.podcast_publishing_banner)
val pickedName = vm.pickedName.value
if (pickedName != null) {
Row(
modifier =
Modifier
.fillMaxWidth()
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(12.dp))
.let { if (!isBusy) it.clickable { wantsToPick = true } else it }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.MusicNote,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text(text = pickedName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(
text = stringRes(R.string.podcast_episode_audio_picked),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (!isBusy) {
TextButton(onClick = { vm.clearPickedMedia() }) { Text(stringRes(R.string.cancel)) }
}
}
} else {
UploadPlaceholder(
iconSymbol = MaterialSymbols.MusicNote,
ctaRes = R.string.podcast_trailer_upload_cta,
hintRes = R.string.podcast_trailer_upload_hint,
onClick = { wantsToPick = true },
aspectRatio = null,
enabled = !isBusy,
)
}
OutlinedTextField(
value = vm.title.value,
onValueChange = { vm.title.value = it },
label = { Text(stringRes(R.string.podcast_episode_title_label)) },
placeholder = { Text(stringRes(R.string.podcast_trailer_title_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
isError = vm.title.value.isBlank(),
)
OutlinedTextField(
value = vm.url.value,
onValueChange = { vm.url.value = it },
label = { Text(stringRes(R.string.podcast_trailer_url_label)) },
placeholder = { Text(stringRes(R.string.podcast_episode_audio_url_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
OutlinedTextField(
value = vm.season.value,
onValueChange = { input -> vm.season.value = input.filter { it.isDigit() } },
label = { Text(stringRes(R.string.podcast_episode_season_label)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
}
}
}
// Audio or video file via OpenDocument.
@Composable
private fun MediaFileSelect(onPicked: (SelectedMedia?) -> Unit) {
val resolver = LocalContext.current.contentResolver
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri: Uri? -> onPicked(uri?.let { SelectedMedia(it, resolver.getType(it)) }) },
)
LaunchedEffect(Unit) { launcher.launch(arrayOf("audio/*", "video/*")) }
}
@@ -0,0 +1,163 @@
/*
* 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.screen.loggedIn.podcasts.authoring
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.withContext
/**
* Composer for a Podcasting-2.0 trailer (`kind:30055`, addressable). A short preview clip: a title,
* one audio/video file (uploaded or pasted as a URL), and an optional season number. Always
* create-new — each trailer gets a fresh `d` tag.
*/
class NewPodcastTrailerViewModel : ViewModel() {
private lateinit var account: Account
val title = mutableStateOf("")
val url = mutableStateOf("")
val season = mutableStateOf("")
val pickedMimeType = mutableStateOf<String?>(null)
val isSending = mutableStateOf(false)
private val _completionEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val completionEvents: SharedFlow<Unit> = _completionEvents.asSharedFlow()
val media = mutableStateOf<MultiOrchestrator?>(null)
val pickedName = mutableStateOf<String?>(null)
val strippingFailureConfirmation = SuspendableConfirmation()
val selectedServer = mutableStateOf<ServerName?>(null)
val mediaQualitySlider = mutableStateOf(1)
val stripMetadata = mutableStateOf(true)
fun init(accountViewModel: AccountViewModel) {
if (::account.isInitialized) return
this.account = accountViewModel.account
this.selectedServer.value = account.settings.defaultFileServer
this.stripMetadata.value = account.settings.stripLocationOnUpload
}
fun setPickedMedia(uri: SelectedMedia?) {
if (uri == null) {
media.value = null
pickedName.value = null
pickedMimeType.value = null
return
}
media.value = MultiOrchestrator(persistentListOf(uri))
pickedName.value = uri.uri.lastPathSegment?.substringAfterLast('/')
pickedMimeType.value = uri.mimeType
}
fun clearPickedMedia() {
media.value = null
pickedName.value = null
pickedMimeType.value = null
}
fun isValid(): Boolean = title.value.isNotBlank() && (media.value != null || url.value.isNotBlank())
fun saveAndPublish(
context: Context,
accountViewModel: AccountViewModel,
) {
if (isSending.value) return
val server = selectedServer.value
if (server == null) {
accountViewModel.toastManager.toast(
"No upload server selected",
"Pick a media server in settings before uploading.",
)
return
}
val titleSnap = title.value.trim()
val urlSnap = url.value.trim()
val seasonSnap = season.value.trim().toIntOrNull()
val mimeSnap = pickedMimeType.value
val mediaSnap = media.value
val quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value)
val strip = stripMetadata.value
val appContext = context.applicationContext
isSending.value = true
accountViewModel.launchSigner {
try {
val finalUrl =
if (mediaSnap != null) {
PodcastComposerMedia.upload(
orchestrator = mediaSnap,
kind = "trailer",
account = account,
server = server,
quality = quality,
stripMetadata = strip,
alt = titleSnap.ifBlank { null },
context = appContext,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
} else {
urlSnap
}
if (finalUrl.isBlank()) {
accountViewModel.toastManager.toast("Trailer upload failed", "No URL was available for the trailer.")
return@launchSigner
}
val template =
Podcasting20TrailerEvent.build(
dTag = PodcastComposerMedia.generateDTag("trailer"),
title = titleSnap,
url = finalUrl,
pubdate = PodcastComposerMedia.rfc2822Now(),
mimeType = mimeSnap,
season = seasonSnap,
)
account.signAndComputeBroadcast(template)
account.settings.changeDefaultFileServer(server)
account.settings.changeStripLocationOnUpload(strip)
_completionEvents.tryEmit(Unit)
} catch (t: Throwable) {
accountViewModel.toastManager.toast("Failed to publish trailer", t.message ?: t.javaClass.simpleName)
} finally {
withContext(Dispatchers.Main.immediate) { isSending.value = false }
}
}
}
}
@@ -0,0 +1,319 @@
/*
* 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.screen.loggedIn.podcasts.authoring
import androidx.compose.foundation.Image
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.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.layout.ContentScale
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import coil3.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.MyPodcastFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
/**
* "Your podcast" — the authoring hub. Shows the creator's own Podcasting-2.0 show (or a create CTA),
* the new-episode / new-trailer / edit-show entry points, and lists the episodes and trailers the
* creator has already published (tap to edit). Data is read from [LocalCache] and refreshed each time
* the screen resumes, so it reflects anything just published from a composer.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PodcastAuthoringScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val me = accountViewModel.account.userProfile().pubkeyHex
// Keep a REQ open for the creator's own catalog while the hub is visible, so the lists below
// fill in from relays even when nothing was cached yet.
MyPodcastFilterAssemblerSubscription(accountViewModel)
// Re-scan LocalCache on each resume (returning from a composer) and whenever the creator's own
// podcast events arrive over the open REQ, so the lists stay current without a manual refresh.
var refresh by remember { mutableIntStateOf(0) }
LifecycleResumeEffect(Unit) {
refresh++
onPauseOrDispose { }
}
LaunchedEffect(me) {
LocalCache.live.newEventBundles.collect { bundle ->
val mine =
bundle.any {
val e = it.event
e?.pubKey == me &&
(
e is Podcasting20EpisodeEvent ||
e is Podcasting20TrailerEvent ||
(e is AppSpecificDataEvent && e.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)
)
}
if (mine) refresh++
}
}
val show =
remember(refresh) {
val address = Address(AppSpecificDataEvent.KIND, me, Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)
(LocalCache.addressables.get(address)?.event as? AppSpecificDataEvent)?.let { Podcasting20PodcastMetadata.parse(it) }
}
val episodes =
remember(refresh) {
LocalCache.addressables
.filterIntoSet { _, note ->
val e = note.event
e is Podcasting20EpisodeEvent && e.pubKey == me
}.mapNotNull { it.event as? Podcasting20EpisodeEvent }
.sortedByDescending { it.createdAt }
}
val trailers =
remember(refresh) {
LocalCache.addressables
.filterIntoSet { _, note ->
val e = note.event
e is Podcasting20TrailerEvent && e.pubKey == me
}.mapNotNull { it.event as? Podcasting20TrailerEvent }
.sortedByDescending { it.createdAt }
}
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.podcast_your_podcast), nav) },
) { pad ->
LazyColumn(
modifier = Modifier.padding(pad).fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { ShowHeaderCard(show, onEdit = { nav.nav(Route.EditPodcastShow) }) }
item {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(
onClick = { nav.nav(Route.NewPodcastEpisode()) },
modifier = Modifier.weight(1f),
) {
Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Text(text = stringRes(R.string.podcast_new_episode), modifier = Modifier.padding(start = 6.dp))
}
OutlinedButton(
onClick = { nav.nav(Route.NewPodcastTrailer) },
modifier = Modifier.weight(1f),
) {
Text(text = stringRes(R.string.podcast_new_trailer))
}
}
}
if (episodes.isNotEmpty()) {
item { SectionHeader(pluralStringResource(R.plurals.podcast_episode_count, episodes.size, episodes.size)) }
items(episodes, key = { it.id }) { ep ->
EpisodeRow(
title = ep.title() ?: stringRes(R.string.podcast_untitled),
subtitle = episodeSubtitle(ep),
onClick = { nav.nav(Route.NewPodcastEpisode(ep.dTag())) },
)
}
}
if (trailers.isNotEmpty()) {
item { SectionHeader(pluralStringResource(R.plurals.podcast_trailer_count, trailers.size, trailers.size)) }
items(trailers, key = { it.id }) { tr ->
EpisodeRow(
title = tr.title() ?: stringRes(R.string.podcast_untitled),
subtitle = tr.url(),
onClick = null,
)
}
}
if (episodes.isEmpty()) {
item {
Text(
text = stringRes(R.string.podcast_no_episodes_yet),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(vertical = 8.dp),
)
}
}
}
}
}
@Composable
private fun ShowHeaderCard(
show: Podcasting20PodcastMetadata?,
onEdit: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onEdit)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
val image = show?.showImage()
Box(
modifier = Modifier.size(64.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
if (!image.isNullOrBlank()) {
Image(
painter = rememberAsyncImagePainter(model = image),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize(),
)
} else {
Icon(symbol = MaterialSymbols.Podcasts, contentDescription = null, modifier = Modifier.size(30.dp))
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = show?.showTitle()?.takeIf { it.isNotBlank() } ?: stringRes(R.string.podcast_create_your_show),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = if (show != null) stringRes(R.string.podcast_tap_to_edit_show) else stringRes(R.string.podcast_create_show_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Icon(symbol = MaterialSymbols.Edit, contentDescription = null, modifier = Modifier.size(20.dp))
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp),
)
}
@Composable
private fun EpisodeRow(
title: String,
subtitle: String?,
onClick: (() -> Unit)?,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.let { if (onClick != null) it.clickable(onClick = onClick) else it }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
symbol = MaterialSymbols.MusicNote,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!subtitle.isNullOrBlank()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (onClick != null) {
Icon(symbol = MaterialSymbols.ChevronRight, contentDescription = null, modifier = Modifier.size(20.dp), tint = Color.Gray)
}
}
}
private fun episodeSubtitle(ep: Podcasting20EpisodeEvent): String? {
val season = ep.season()
val number = ep.number()
return when {
season != null && number != null -> "S$season · E$number"
number != null -> "Ep $number"
season != null -> "Season $season"
else -> null
}
}
@@ -0,0 +1,131 @@
/*
* 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.screen.loggedIn.podcasts.authoring
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
/**
* Shared upload + media-probe mechanics for the Podcasting-2.0 composers (episode, show, trailer).
* Mirrors the music-track composer's approach but kept in one place so all three authoring
* ViewModels resolve a picked file to a hosted URL the same way.
*/
object PodcastComposerMedia {
class UploadException(
kind: String,
details: String,
) : Exception("$kind upload failed: $details")
/**
* Uploads a single picked file (cover image or audio) through the user's media server and
* returns the hosted URL. Throws [UploadException] if the server rejects it or returns no URL.
*/
suspend fun upload(
orchestrator: MultiOrchestrator,
kind: String,
account: Account,
server: ServerName,
quality: CompressorQuality,
stripMetadata: Boolean,
alt: String?,
context: Context,
onStrippingFailed: suspend () -> Boolean,
): String {
val res =
orchestrator.upload(
alt = alt,
contentWarningReason = null,
mediaQuality = quality,
server = server,
account = account,
context = context,
useH265 = false,
stripMetadata = stripMetadata,
onStrippingFailed = onStrippingFailed,
)
if (!res.allGood) throw UploadException(kind, formatUploadErrors(res.errors, context))
return firstUploadedUrl(res.successful)
?: throw UploadException(kind, "Server didn't return a URL for the uploaded $kind.")
}
private fun firstUploadedUrl(successful: List<UploadingState.Finished>): String? =
successful
.firstNotNullOfOrNull { it.result as? UploadOrchestrator.OrchestratorResult.ServerResult }
?.url
private fun formatUploadErrors(
errors: List<UploadingState.Error>,
context: Context,
): String =
errors
.map { context.getString(it.errorResource, *it.params) }
.distinct()
.joinToString(".\n")
/** Audio metadata read off a picked file, used to auto-fill the composer. Any field may be null. */
class ProbedAudio(
val durationSeconds: Int?,
val title: String?,
)
/**
* Reads duration + title from a picked audio file via [MediaMetadataRetriever] (heavy — call off
* the main thread). Returns null if the provider rejects it or the file isn't a real container.
*/
fun probeAudio(
context: Context,
uri: Uri,
): ProbedAudio? {
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(context, uri)
val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
ProbedAudio(
durationSeconds = durationMs?.let { (it / 1000).toInt().takeIf { secs -> secs > 0 } },
title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)?.trim()?.ifBlank { null },
)
} catch (_: Exception) {
null
} finally {
retriever.release()
}
}
/** Current time as an RFC2822 date string (`Tue, 24 Jun 2025 12:00:00 GMT`) — the spec's `pubdate`. */
fun rfc2822Now(): String = DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneId.of("GMT")))
/** A fresh, stable `d` tag for a new addressable episode/trailer. */
fun generateDTag(prefix: String): String = "$prefix-${System.currentTimeMillis() / 1000}-${UUID.randomUUID().toString().take(8)}"
/** Splits a comma-separated text field into a clean list (trimmed, no blanks). */
fun parseCsv(text: String): List<String> = text.split(',').map { it.trim() }.filter { it.isNotEmpty() }
}
@@ -0,0 +1,331 @@
/*
* 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.screen.loggedIn.podcasts.authoring
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.FilterChip
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.podcasts.RecipientDraft
import com.vitorpamplona.amethyst.commons.podcasts.V4VSplitEditorState
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.grayText
/**
* Editor for a Podcasting-2.0 value-for-value split. Recipients are added the Amethyst-native way —
* search for a Nostr user and they're rendered with avatar + name, their lightning address resolved
* automatically — with a manual "add address" fallback for raw lightning addresses or node keysend
* destinations. Each recipient's share of incoming sats is shown live as a percentage of the total
* weight. Drives a [V4VSplitEditorState]; the owning composer reads [V4VSplitEditorState.toPodcastValue].
*/
@Composable
fun V4VSplitEditor(
state: V4VSplitEditorState,
accountViewModel: AccountViewModel,
) {
val total = state.totalSplit()
val userSuggestions =
remember { UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) }
var search by remember { mutableStateOf("") }
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringRes(R.string.podcast_value_for_value),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
if (state.recipients.isEmpty()) {
Text(
text = stringRes(R.string.podcast_value_editor_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
state.recipients.forEach { draft ->
if (draft.user.value != null) {
UserRecipientCard(draft, total, accountViewModel, onRemove = { state.remove(draft) })
} else {
ManualRecipientCard(draft, total, onRemove = { state.remove(draft) })
}
}
// Search a Nostr user to add (resolves their lightning address). Beautiful path.
OutlinedTextField(
value = search,
onValueChange = { newValue ->
search = newValue
if (newValue.length > 2) userSuggestions.processCurrentWord(newValue) else userSuggestions.reset()
},
label = { Text(stringRes(R.string.podcast_value_search_user)) },
placeholder = { Text(stringRes(R.string.podcast_value_search_user_hint)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
if (search.length > 2) {
ShowUserSuggestionList(
userSuggestions = userSuggestions,
onSelect = { user ->
val added = state.addUser(user)
if (!added) {
accountViewModel.toastManager.toast(
R.string.podcast_value_for_value,
R.string.podcast_value_user_no_lnaddress,
)
}
search = ""
userSuggestions.reset()
},
accountViewModel = accountViewModel,
modifier = SuggestionListDefaultHeightPage,
)
}
// Fallback for raw destinations (a node pubkey for keysend, or a non-Nostr lightning address).
TextButton(onClick = { state.addManual() }, modifier = Modifier.fillMaxWidth()) {
Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Text(text = stringRes(R.string.podcast_value_add_address), modifier = Modifier.padding(start = 6.dp))
}
}
}
@Composable
private fun RecipientShell(
total: Int,
draft: RecipientDraft,
onRemove: () -> Unit,
content: @Composable RowScope.() -> Unit,
) {
val weight =
draft.split.value
.trim()
.toIntOrNull() ?: 0
val percent = if (total > 0 && weight > 0) weight * 100 / total else 0
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
content()
Text(
text = stringRes(R.string.podcast_value_split_percent, percent),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
IconButton(onClick = onRemove) {
Icon(
symbol = MaterialSymbols.Delete,
contentDescription = stringRes(R.string.podcast_value_remove_recipient),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
WeightAndFeeRow(draft, weight)
}
}
@Composable
private fun UserRecipientCard(
draft: RecipientDraft,
total: Int,
accountViewModel: AccountViewModel,
onRemove: () -> Unit,
) {
val user = draft.user.value ?: return
RecipientShell(total, draft, onRemove) {
BaseUserPicture(user, Size40dp, accountViewModel = accountViewModel)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
UsernameDisplay(user, accountViewModel = accountViewModel)
val lud = user.lnAddress()
Text(
text = lud ?: stringRes(R.string.podcast_value_user_no_lnaddress),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
)
}
}
}
@Composable
private fun ManualRecipientCard(
draft: RecipientDraft,
total: Int,
onRemove: () -> Unit,
) {
val isNode by draft.isNode
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val weight =
draft.split.value
.trim()
.toIntOrNull() ?: 0
val percent = if (total > 0 && weight > 0) weight * 100 / total else 0
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringRes(R.string.podcast_value_split_percent, percent),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onRemove) {
Icon(
symbol = MaterialSymbols.Delete,
contentDescription = stringRes(R.string.podcast_value_remove_recipient),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
OutlinedTextField(
value = draft.name.value,
onValueChange = { draft.name.value = it },
label = { Text(stringRes(R.string.podcast_value_recipient_name)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = !isNode,
onClick = { draft.isNode.value = false },
label = { Text(stringRes(R.string.podcast_value_type_lnaddress)) },
)
FilterChip(
selected = isNode,
onClick = { draft.isNode.value = true },
label = { Text(stringRes(R.string.podcast_value_type_node)) },
)
}
OutlinedTextField(
value = draft.address.value,
onValueChange = { draft.address.value = it },
label = { Text(if (isNode) stringRes(R.string.podcast_value_node_pubkey) else stringRes(R.string.podcast_value_lnaddress)) },
placeholder = {
Text(if (isNode) stringRes(R.string.podcast_value_node_pubkey_hint) else stringRes(R.string.podcast_value_lnaddress_hint))
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = draft.address.value.isBlank(),
)
WeightAndFeeRow(draft, weight)
}
}
@Composable
private fun WeightAndFeeRow(
draft: RecipientDraft,
weight: Int,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = draft.split.value,
onValueChange = { input -> draft.split.value = input.filter { it.isDigit() } },
label = { Text(stringRes(R.string.podcast_value_weight)) },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = weight <= 0,
)
FilterChip(
selected = draft.fee.value,
onClick = { draft.fee.value = !draft.fee.value },
label = { Text(stringRes(R.string.podcast_value_fee)) },
)
}
}
@@ -23,15 +23,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
/**
* Every episode of a single podcast. Per NIP-F4 each podcast is its own keypair, so all
* episodes (kind 54) of a show are authored by [podcastPubkey]. Episodes are regular events,
* so they live in `LocalCache.notes`. Most-recent-first via [sortedByDefaultFeedOrder].
* Everything a single podcast (authored by [podcastPubkey]) publishes for its show page: NIP-F4
* episodes (kind 54, regular events in `LocalCache.notes`), Podcasting-2.0 episodes (kind 30054)
* and Podcasting-2.0 trailers (kind 30055, both addressable events in `LocalCache.addressables`).
* Episodes go through the shared [PodcastEpisode] interface; trailers are matched by their concrete
* type and rendered distinctly. In every model the show's pubkey authors its own content.
* Most-recent-first via [sortedByDefaultFeedOrder].
*/
class OnePodcastEpisodesFeedFilter(
val podcastPubkey: HexKey,
@@ -41,18 +47,27 @@ class OnePodcastEpisodesFeedFilter(
override fun feedKey(): String = "podcast-" + podcastPubkey
override fun feed(): List<Note> {
val notes =
val regular =
cache.notes.filterIntoSet { _, it ->
acceptableEvent(it)
}
return sort(notes)
val episodes =
cache.addressables.filterIntoSet(Podcasting20EpisodeEvent.KIND) { _, it ->
acceptableEvent(it)
}
val trailers =
cache.addressables.filterIntoSet(Podcasting20TrailerEvent.KIND) { _, it ->
acceptableEvent(it)
}
return sort(regular + episodes + trailers)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { acceptableEvent(it) }
private fun acceptableEvent(note: Note): Boolean {
val noteEvent = note.event
return noteEvent is PodcastEpisodeEvent &&
val noteEvent = note.event ?: return false
val isShowContent = noteEvent is PodcastEpisode || noteEvent is Podcasting20TrailerEvent
return isShowContent &&
noteEvent.pubKey == podcastPubkey &&
!note.isHiddenFor(account.hiddenUsers.flow.value) &&
account.isAcceptable(note)
@@ -28,12 +28,14 @@ import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
/**
* Episodes are regular events (kind 54), so they live in `LocalCache.notes` — unlike
* music tracks/playlists which are addressable. Same follow-list + hidden/blocked
* gate as MusicTracksFeedFilter.
* Merges the two podcast-episode kinds into one list. NIP-F4 episodes (kind 54) are regular
* events in `LocalCache.notes`; Podcasting-2.0 episodes (kind 30054) are addressable events in
* `LocalCache.addressables`. Both implement [PodcastEpisode], so a single accept gate covers
* them. Same follow-list + hidden/blocked gate as MusicTracksFeedFilter.
*/
class PodcastEpisodesFeedFilter(
val account: Account,
@@ -54,11 +56,15 @@ class PodcastEpisodesFeedFilter(
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
val regular =
LocalCache.notes.filterIntoSet { _, it ->
accept(it, params)
}
return sort(notes)
val addressable =
LocalCache.addressables.filterIntoSet(Podcasting20EpisodeEvent.KIND) { _, it ->
accept(it, params)
}
return sort(regular + addressable)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
@@ -78,8 +84,8 @@ class PodcastEpisodesFeedFilter(
note: Note,
params: FilterByListParams,
): Boolean {
val noteEvent = note.event
return noteEvent is PodcastEpisodeEvent &&
val noteEvent = note.event ?: return false
return noteEvent is PodcastEpisode &&
params.match(noteEvent, note.relays) &&
(params.isHiddenList || account.isAcceptable(note))
}
@@ -28,11 +28,15 @@ import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastShowEvent
/**
* Show-level podcast metadata (kind 10154) is a replaceable event, so it lives in
* `LocalCache.addressables` — same shape as the music-playlists filter.
* Merges show-level podcast metadata from both drafts into one list. NIP-F4 shows (kind 10154)
* and Podcasting-2.0 shows (kind 30078 NIP-78 app-data with `d="podcast-metadata"`) are both
* replaceable, so they live in `LocalCache.addressables`. [isPodcastShowEvent] gates inclusion
* (the kind-30078 scan would otherwise see unrelated app-data, so the `d`-tag check matters).
*/
class PodcastsFeedFilter(
val account: Account,
@@ -53,11 +57,15 @@ class PodcastsFeedFilter(
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
val f4 =
LocalCache.addressables.filterIntoSet(PodcastMetadataEvent.KIND) { _, it ->
accept(it, params)
}
return sort(notes)
val podcasting20 =
LocalCache.addressables.filterIntoSet(AppSpecificDataEvent.KIND) { _, it ->
accept(it, params)
}
return sort(f4 + podcasting20)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
@@ -77,8 +85,8 @@ class PodcastsFeedFilter(
note: Note,
params: FilterByListParams,
): Boolean {
val noteEvent = note.event
return noteEvent is PodcastMetadataEvent &&
val noteEvent = note.event ?: return false
return isPodcastShowEvent(noteEvent) &&
params.match(noteEvent, note.relays) &&
(params.isHiddenList || account.isAcceptable(note))
}
@@ -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.ui.screen.loggedIn.podcasts.datasource
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
/**
* REQ for the logged-in creator's own Podcasting-2.0 catalog, so the authoring hub reliably shows
* their show, episodes and trailers even on a fresh install (rather than only what happens to be in
* [LocalCache]). Queried on the creator's own outbox relays.
*
* Two separate filters per relay, and they can't be merged:
* - episodes (kind 30054) + trailers (kind 30055) by author, with no tag constraint; and
* - the show metadata (kind 30078) constrained to `#d=["podcast-metadata"]`. That NIP-78 app-data
* kind is heavily overloaded, so without the `#d` constraint this REQ would pull every app's data
* (including the user's private settings) for the pubkey. The constraint must stay off the episodes
* filter, since each episode/trailer carries its own `d` tag — a combined `#d` would match nothing.
*/
fun filterMyPodcast(
user: User,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val relays =
user.outboxRelays()?.ifEmpty { null }
?: (user.allUsedRelays() + LocalCache.relayHints.hintsForKey(user.pubkeyHex))
val authors = listOf(user.pubkeyHex)
return relays.flatMap { relay ->
val sinceTime = since?.get(relay)?.time
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authors,
kinds = listOf(Podcasting20EpisodeEvent.KIND, Podcasting20TrailerEvent.KIND),
since = sinceTime,
limit = 200,
),
),
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authors,
kinds = listOf(AppSpecificDataEvent.KIND),
tags = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)),
since = sinceTime,
limit = 10,
),
),
)
}
}
@@ -25,16 +25,22 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
// Per NIP-F4 a podcast is its own keypair: the show-level metadata (kind 10154) and every
// episode (kind 54) are authored by the same pubkey. So a single-podcast screen fetches
// both kinds from that one author.
// A single-podcast screen fetches everything authored by the show's pubkey, across both drafts:
// NIP-F4 metadata (kind 10154) + episodes (kind 54), and Podcasting-2.0 episodes (kind 30054) +
// trailers (kind 30055). In both models the show key authors its own episodes and trailers.
private val OnePodcastKinds =
listOf(
PodcastMetadataEvent.KIND,
PodcastEpisodeEvent.KIND,
Podcasting20EpisodeEvent.KIND,
Podcasting20TrailerEvent.KIND,
)
fun filterOnePodcast(
@@ -47,16 +53,31 @@ fun filterOnePodcast(
user.outboxRelays()?.ifEmpty { null }
?: (user.allUsedRelays() + LocalCache.relayHints.hintsForKey(user.pubkeyHex))
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = OnePodcastKinds,
authors = listOf(user.pubkeyHex),
limit = 500,
since = since?.get(relay)?.time,
),
return relays.flatMap { relay ->
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = OnePodcastKinds,
authors = listOf(user.pubkeyHex),
limit = 500,
since = since?.get(relay)?.time,
),
),
// Podcasting-2.0 show metadata rides on the generic NIP-78 app-data kind (30078),
// so it needs its own #d=podcast-metadata filter rather than a bare kind match.
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(AppSpecificDataEvent.KIND),
authors = listOf(user.pubkeyHex),
tags = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)),
limit = 1,
since = since?.get(relay)?.time,
),
),
)
}
}
@@ -0,0 +1,38 @@
/*
* 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.screen.loggedIn.podcasts.datasource
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class MyPodcastFeedSubAssembler(
client: INostrClient,
allKeys: () -> Set<MyPodcastQueryState>,
) : PerUserEoseManager<MyPodcastQueryState>(client, allKeys) {
override fun updateFilter(
key: MyPodcastQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> = filterMyPodcast(user(key), since)
override fun user(key: MyPodcastQueryState) = key.user
}
@@ -0,0 +1,46 @@
/*
* 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.screen.loggedIn.podcasts.datasource
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
// Keyed by the logged-in creator's own pubkey. Drives the authoring hub's REQ for the creator's
// own Podcasting-2.0 catalog (episodes, trailers, and the show-metadata event).
class MyPodcastQueryState(
val user: User,
)
class MyPodcastFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<MyPodcastQueryState>() {
val group =
listOf(
MyPodcastFeedSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,41 @@
/*
* 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.screen.loggedIn.podcasts.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* While the authoring hub is on screen, keeps a REQ open for the logged-in creator's own
* Podcasting-2.0 catalog (episodes, trailers, and show metadata), so the hub fills in even when the
* events weren't already cached.
*/
@Composable
fun MyPodcastFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
val state =
remember(accountViewModel) {
MyPodcastQueryState(accountViewModel.account.userProfile())
}
LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().myPodcast)
}
@@ -30,8 +30,10 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopN
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCASTING20_METADATA_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_EPISODE_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_METADATA_D_FILTER
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByCommunity
@@ -52,22 +54,28 @@ fun makePodcastsFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> = makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince)
): List<RelayBasedFilter> =
// Two REQs: NIP-F4 shows (kind:10154, no tag constraint) plus Podcasting-2.0 shows
// (kind:30078, constrained to `#d=["podcast-metadata"]` so the overloaded app-data kind
// doesn't flood the feed). Both land in the merged PodcastsFeedFilter.
makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince) +
makePodcastFilter(feedSettings, PODCASTING20_METADATA_KINDS, since, defaultSince, PODCAST_METADATA_D_FILTER)
private fun makePodcastFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long?,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> =
when (feedSettings) {
is AllCommunitiesTopNavPerRelayFilterSet -> filterPodcastEventsByAllCommunities(feedSettings, kinds, since, defaultSince)
is AllFollowsTopNavPerRelayFilterSet -> filterPodcastEventsByFollows(feedSettings, kinds, since, defaultSince)
is AuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByAuthors(feedSettings, kinds, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterPodcastEventsGlobal(feedSettings, kinds, since, defaultSince)
is HashtagTopNavPerRelayFilterSet -> filterPodcastEventsByHashtag(feedSettings, kinds, since, defaultSince)
is LocationTopNavPerRelayFilterSet -> filterPodcastEventsByGeohashes(feedSettings, kinds, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince)
is AllCommunitiesTopNavPerRelayFilterSet -> filterPodcastEventsByAllCommunities(feedSettings, kinds, since, defaultSince, additionalTags)
is AllFollowsTopNavPerRelayFilterSet -> filterPodcastEventsByFollows(feedSettings, kinds, since, defaultSince, additionalTags)
is AuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByAuthors(feedSettings, kinds, since, defaultSince, additionalTags)
is GlobalTopNavPerRelayFilterSet -> filterPodcastEventsGlobal(feedSettings, kinds, since, defaultSince, additionalTags)
is HashtagTopNavPerRelayFilterSet -> filterPodcastEventsByHashtag(feedSettings, kinds, since, defaultSince, additionalTags)
is LocationTopNavPerRelayFilterSet -> filterPodcastEventsByGeohashes(feedSettings, kinds, since, defaultSince, additionalTags)
is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince, additionalTags)
is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince, additionalTags)
else -> emptyList()
}
@@ -27,14 +27,42 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
// Each podcast screen issues its own REQ keyed on its own follow-list selector — keep
// the kind lists split so episodes and show metadata never co-mingle in one filter.
internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND)
// Episodes span both podcast drafts: NIP-F4 kind 54 (podcast-is-a-keypair) and Podcasting-2.0
// kind 30054 (creator-is-a-keypair). Both are authored by the followed pubkey, so the same
// author-scoped REQ pulls them into one merged feed.
internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND, Podcasting20EpisodeEvent.KIND)
internal val PODCAST_KINDS = listOf(PodcastMetadataEvent.KIND)
// Podcasting-2.0 stores show metadata as a kind:30078 NIP-78 app-data event. That kind is
// heavily overloaded, so this REQ MUST be constrained by `#d=["podcast-metadata"]` (see
// [PODCAST_METADATA_D_FILTER]) or it would pull every client's app-data. Kept separate from
// [PODCAST_KINDS] because the NIP-F4 kind:10154 metadata must NOT carry the `#d` constraint.
internal val PODCASTING20_METADATA_KINDS = listOf(AppSpecificDataEvent.KIND)
internal val PODCAST_METADATA_D_FILTER = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG))
/**
* Merges two relay-filter tag maps, unioning the value lists per key. Used to layer an extra
* constraint (e.g. `#d`) onto a variant that already pins its own tags (`#t`, `#g`, `#a`).
* A null on either side passes the other through unchanged.
*/
internal fun mergeFilterTags(
base: Map<String, List<String>>?,
extra: Map<String, List<String>>?,
): Map<String, List<String>>? =
when {
base == null -> extra
extra == null -> base
else -> (base.keys + extra.keys).associateWith { (base[it].orEmpty() + extra[it].orEmpty()).distinct() }
}
// NOTE on TopFilter.AllFollows / Following for the Podcasts tab: per NIP-F4 each podcast is
// its own keypair, so podcast pubkeys generally aren't in the user's kind:3 contact list —
// they live in kind:10054 (FavoritePodcastsListEvent) and kind:10064 (AuthoredPodcastsEvent).
@@ -47,6 +75,7 @@ fun filterPodcastEventsByAuthors(
kinds: List<Int>,
authors: Set<HexKey>,
since: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
val authorList = authors.sorted()
return listOf(
@@ -56,6 +85,7 @@ fun filterPodcastEventsByAuthors(
Filter(
authors = authorList,
kinds = kinds,
tags = additionalTags,
limit = 200,
since = since,
),
@@ -68,6 +98,7 @@ fun filterPodcastEventsByAuthors(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
@@ -81,6 +112,7 @@ fun filterPodcastEventsByAuthors(
kinds = kinds,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
additionalTags = additionalTags,
)
}
}.flatten()
@@ -91,6 +123,7 @@ fun filterPodcastEventsByMutedAuthors(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
@@ -104,6 +137,7 @@ fun filterPodcastEventsByMutedAuthors(
kinds = kinds,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
additionalTags = additionalTags,
)
}
}.flatten()
@@ -33,6 +33,7 @@ fun filterPodcastEventsFromAllCommunities(
kinds: List<Int>,
communities: Set<String>,
since: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
val communityList = communities.sorted()
val kindsAsStrings = kinds.map { it.toString() }
@@ -57,7 +58,7 @@ fun filterPodcastEventsFromAllCommunities(
filter =
Filter(
kinds = kinds,
tags = mapOf("a" to communityList),
tags = mergeFilterTags(mapOf("a" to communityList), additionalTags),
limit = communityList.size * 20,
since = since,
),
@@ -70,6 +71,7 @@ fun filterPodcastEventsByAllCommunities(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (communitySet.set.isEmpty()) return emptyList()
@@ -79,6 +81,7 @@ fun filterPodcastEventsByAllCommunities(
kinds = kinds,
communities = it.value.communities,
since = since?.get(it.key)?.time ?: defaultSince,
additionalTags = additionalTags,
)
}
}
@@ -89,6 +92,7 @@ fun filterPodcastEventsFromCommunity(
community: String,
authors: Set<String>?,
since: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
val authorList = authors?.sorted()
val kindsAsStrings = kinds.map { it.toString() }
@@ -114,7 +118,7 @@ fun filterPodcastEventsFromCommunity(
Filter(
authors = authorList,
kinds = kinds,
tags = mapOf("a" to listOf(community)),
tags = mergeFilterTags(mapOf("a" to listOf(community)), additionalTags),
limit = 100,
since = since,
),
@@ -127,6 +131,7 @@ fun filterPodcastEventsByCommunity(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (communitySet.set.isEmpty()) return emptyList()
@@ -137,6 +142,7 @@ fun filterPodcastEventsByCommunity(
community = it.value.community,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
additionalTags = additionalTags,
)
}
}
@@ -29,6 +29,7 @@ fun filterPodcastEventsByFollows(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (followsSet.set.isEmpty()) return emptyList()
@@ -38,7 +39,7 @@ fun filterPodcastEventsByFollows(
listOfNotNull(
it.value.authors?.let { authors ->
filterPodcastEventsByAuthors(relay, kinds, authors, sinceForRelay)
filterPodcastEventsByAuthors(relay, kinds, authors, sinceForRelay, additionalTags)
},
).flatten()
}
@@ -31,6 +31,7 @@ fun filterPodcastEventsByGeohashes(
kinds: List<Int>,
geotags: Set<String>,
since: Long?,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (geotags.isEmpty()) return emptyList()
@@ -40,7 +41,7 @@ fun filterPodcastEventsByGeohashes(
filter =
Filter(
kinds = kinds,
tags = mapOf("g" to geotags.sorted()),
tags = mergeFilterTags(mapOf("g" to geotags.sorted()), additionalTags),
limit = 100,
since = since,
),
@@ -53,6 +54,7 @@ fun filterPodcastEventsByGeohashes(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long?,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (geoSet.set.isEmpty()) return emptyList()
@@ -66,6 +68,7 @@ fun filterPodcastEventsByGeohashes(
kinds = kinds,
geotags = it.value.geotags,
since = since?.get(it.key)?.time ?: defaultSince,
additionalTags = additionalTags,
)
}
}.flatten()
@@ -31,6 +31,7 @@ fun filterPodcastEventsByHashtag(
kinds: List<Int>,
hashtags: Set<String>,
since: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
@@ -38,7 +39,7 @@ fun filterPodcastEventsByHashtag(
filter =
Filter(
kinds = kinds,
tags = mapOf("t" to hashtags.sorted()),
tags = mergeFilterTags(mapOf("t" to hashtags.sorted()), additionalTags),
limit = 200,
since = since,
),
@@ -50,6 +51,7 @@ fun filterPodcastEventsByHashtag(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (hashtagSet.set.isEmpty()) return emptyList()
@@ -63,6 +65,7 @@ fun filterPodcastEventsByHashtag(
kinds = kinds,
hashtags = it.value.hashtags,
since = since?.get(it.key)?.time ?: defaultSince,
additionalTags = additionalTags,
)
}
}.flatten()
@@ -30,6 +30,7 @@ fun filterPodcastEventsGlobal(
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
additionalTags: Map<String, List<String>>? = null,
): List<RelayBasedFilter> {
if (relays.set.isEmpty()) return emptyList()
@@ -42,6 +43,7 @@ fun filterPodcastEventsGlobal(
filter =
Filter(
kinds = kinds,
tags = additionalTags,
limit = 200,
since = sinceForRelay,
),
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.SearchRelayLi
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -114,7 +115,11 @@ private fun Explanation(postViewModel: SearchRelayListViewModel) {
Spacer(modifier = StdVertSpacer)
Text(
text = stringRes(id = R.string.search_relays_not_found_examples),
text =
stringRes(
R.string.search_relays_not_found_examples,
DefaultSearchRelayList.joinToString("\n") { " - ${it.displayUrl()}" },
),
)
Spacer(modifier = StdVertSpacer)
@@ -220,6 +220,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.LevelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay
import com.vitorpamplona.amethyst.ui.stringRes
@@ -324,6 +325,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
@@ -341,6 +343,9 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
@@ -832,8 +837,15 @@ private fun FullBleedNoteCompose(
RenderMusicPlaylist(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is PodcastEpisodeEvent) {
RenderPodcastEpisode(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is Podcasting20EpisodeEvent) {
RenderPodcastEpisode(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is Podcasting20TrailerEvent) {
PodcastTrailerListItem(baseNote, accountViewModel, nav)
} else if (noteEvent is PodcastMetadataEvent) {
RenderPodcastMetadata(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AppSpecificDataEvent && noteEvent.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) {
// kind:30078 is overloaded; only the Podcasting-2.0 show-metadata variant renders as a podcast card.
RenderPodcastMetadata(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is CommunityPostApprovalEvent) {
RenderPostApproval(
baseNote,
@@ -2265,7 +2265,7 @@
<string name="search_relays_not_found">أعد مرحلات البحث</string>
<string name="search_relays_not_found_description">إنشاء قائمة مرحلات مخصصة للبحث سوف يحسن من نتائج البحث.</string>
<string name="search_relays_not_found_editing">أدخل من واحد إلى ثلاث مرحلات لاستخدامهم عند البحث عن محتوى أو اشخاص، تأكد من أن هذا المرحلات تدعم NIP-50</string>
<string name="search_relays_not_found_examples">بعض المرحلات الجيدة:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">بعض المرحلات الجيدة:\n%1$s</string>
<string name="outbox_relays_not_found">أعدّ Public Outbox relays للنشر</string>
<string name="outbox_relays_not_found_description">إنشاء قائمة relay مخصصة لاستقبال محتواك أمر بالغ الأهمية لتجربتك على Nostr، وهو الطريقة الوحيدة التي يمكن من خلالها لمتابعيك إيجادك.</string>
<string name="outbox_relays_not_found_editing">أدخل ما بين 1 و3 relays تستقبل منشوراتك. تأكد من أنها لا تتطلب دفعًا إذا لم تكن تدفع للإدراج</string>
@@ -2147,7 +2147,7 @@
<string name="search_relays_not_found">আপনার সার্চ relay সেট আপ করুন</string>
<string name="search_relays_not_found_description">বিশেষভাবে সার্চ এবং ব্যবহারকারী ট্যাগিংয়ের জন্য ডিজাইন করা একটি relay তালিকা তৈরি করলে ফলাফল উন্নত হবে।</string>
<string name="search_relays_not_found_editing">কন্টেন্ট বা ব্যবহারকারী সার্চ করতে এবং ট্যাগিংয়ের জন্য ১–৩টি relay ঢোকান। নিশ্চিত করুন যে আপনার বেছে নেওয়া relay গুলো NIP-50 সমর্থন করে</string>
<string name="search_relays_not_found_examples">ভালো বিকল্পগুলো হলো:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">ভালো বিকল্পগুলো হলো:\n%1$s</string>
<string name="outbox_relays_title">আউটবক্স Relay</string>
<string name="outbox_relays_not_found">পোস্ট করতে আপনার পাবলিক আউটবক্স relay সেট আপ করুন</string>
<string name="outbox_relays_not_found_description">আপনার কন্টেন্ট গ্রহণের জন্য বিশেষভাবে ডিজাইন করা একটি relay তালিকা তৈরি করা আপনার Nostr অভিজ্ঞতার জন্য অত্যন্ত গুরুত্বপূর্ণ এবং আপনার অনুসরণকারীরা আপনাকে খুঁজে পাওয়ার একমাত্র উপায়। </string>
@@ -2237,7 +2237,7 @@
<string name="search_relays_not_found">Nastavte svá vyhledávací relé</string>
<string name="search_relays_not_found_description">Vytvoření seznamu relé speciálně navržených pro vyhledávání a označování uživatelů zlepší tyto výsledky.</string>
<string name="search_relays_not_found_editing">Vložte mezi 1–3 relé, která chcete použít při vyhledávání obsahu nebo označování uživatelů. Ujistěte se, že vaše vybraná relé implementují NIP-50</string>
<string name="search_relays_not_found_examples">Dobré možnosti jsou:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">Dobré možnosti jsou:\n%1$s</string>
<string name="outbox_relays_title">Relaye pro odesílání</string>
<string name="outbox_relays_not_found">Nastavte své veřejné relaye pro odesílání příspěvků</string>
<string name="outbox_relays_not_found_description">Vytvoření seznamu relayů určených pro přijímání vašeho obsahu je zásadní pro váš zážitek z Nostr a jediný způsob, jak vás mohou vaši sledující najít.</string>
+1 -1
View File
@@ -649,7 +649,7 @@
<string name="search_relays_not_found">Nastavte svá vyhledávací relé</string>
<string name="search_relays_not_found_description">Vytvoření seznamu relé speciálně navržených pro vyhledávání a označování uživatelů zlepší tyto výsledky.</string>
<string name="search_relays_not_found_editing">Vložte mezi 1–3 relé, která chcete použít při vyhledávání obsahu nebo označování uživatelů. Ujistěte se, že vaše vybraná relé implementují NIP-50</string>
<string name="search_relays_not_found_examples">Dobré možnosti jsou:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">Dobré možnosti jsou:\n%1$s</string>
<string name="relay_settings">Nastavení relé</string>
<string name="public_home_section">Veřejná domácí relé</string>
<string name="public_home_section_explainer">Tento typ relé ukládá veškerý váš obsah. Amethyst sem pošle vaše příspěvky a ostatní použijí tato relé, aby našli váš obsah. Vložte mezi 1–3 relé. Mohou to být osobní relé, placená relé nebo veřejná relé.</string>
@@ -2186,7 +2186,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="search_relays_not_found">Richten Sie Ihre Suchrelais ein</string>
<string name="search_relays_not_found_description">Das Erstellen einer Relaisliste, die speziell für die Suche und Benutzerkennzeichnung entwickelt wurde, wird diese Ergebnisse verbessern.</string>
<string name="search_relays_not_found_editing">Fügen Sie 1–3 Relais ein, die beim Suchen nach Inhalten oder beim Taggen von Benutzern verwendet werden sollen. Stellen Sie sicher, dass Ihre ausgewählten Relais NIP-50 implementieren</string>
<string name="search_relays_not_found_examples">Gute Optionen sind:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">Gute Optionen sind:\n%1$s</string>
<string name="outbox_relays_title">Ausgehende Relays</string>
<string name="outbox_relays_not_found">Richte deine öffentlichen Ausgehenden Relays zum Posten ein</string>
<string name="outbox_relays_not_found_description">Das Erstellen einer Relay-Liste, die speziell zum Empfangen deiner Inhalte gedacht ist, ist entscheidend für dein Nostr-Erlebnis und der einzige Weg, wie dich deine Follower finden können.</string>
+1 -1
View File
@@ -655,7 +655,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="search_relays_not_found">Richten Sie Ihre Suchrelais ein</string>
<string name="search_relays_not_found_description">Das Erstellen einer Relaisliste, die speziell für die Suche und Benutzerkennzeichnung entwickelt wurde, wird diese Ergebnisse verbessern.</string>
<string name="search_relays_not_found_editing">Fügen Sie 1–3 Relais ein, die beim Suchen nach Inhalten oder beim Taggen von Benutzern verwendet werden sollen. Stellen Sie sicher, dass Ihre ausgewählten Relais NIP-50 implementieren</string>
<string name="search_relays_not_found_examples">Gute Optionen sind:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">Gute Optionen sind:\n%1$s</string>
<string name="relay_settings">Relaiseinstellungen</string>
<string name="public_home_section">Öffentliche Heimrelais</string>
<string name="public_home_section_explainer">Dieser Relais-Typ speichert alle Ihre Inhalte. Amethyst sendet Ihre Beiträge hierher und andere werden diese Relais verwenden, um Ihre Inhalte zu finden. Fügen Sie 1–3 Relais ein. Sie können persönliche Relais, bezahlte Relais oder öffentliche Relais sein.</string>
@@ -2127,7 +2127,7 @@
<string name="search_relays_not_found">Ρυθμίστε τα relay αναζήτησής σας</string>
<string name="search_relays_not_found_description">Η δημιουργία λίστας relay ειδικά σχεδιασμένης για αναζήτηση και επισήμανση χρηστών θα βελτιώσει αυτά τα αποτελέσματα.</string>
<string name="search_relays_not_found_editing">Εισάγετε από 1 έως 3 relay για χρήση κατά την αναζήτηση περιεχομένου ή επισήμανση χρηστών. Βεβαιωθείτε ότι τα επιλεγμένα relay υλοποιούν NIP-50</string>
<string name="search_relays_not_found_examples">Καλές επιλογές είναι:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">Καλές επιλογές είναι:\n%1$s</string>
<string name="outbox_relays_title">Relay Εξερχόμενων</string>
<string name="outbox_relays_not_found">Ρυθμίστε τα δημόσια relay εξερχόμενων για ανάρτηση</string>
<string name="outbox_relays_not_found_description">Η δημιουργία λίστας relay ειδικά σχεδιασμένης για τη λήψη του περιεχομένου σας είναι κρίσιμη για την εμπειρία σας στο Nostr και ο μόνος τρόπος για να σας βρουν οι ακόλουθοί σας. </string>
@@ -2151,7 +2151,7 @@
<string name="search_relays_not_found">Agordu viajn Serĉajn relay-ojn</string>
<string name="search_relays_not_found_description">Krei relay-liston specife dezajnitan por serĉo kaj uzanta etikedado plibonigos ĉi tiujn rezultojn.</string>
<string name="search_relays_not_found_editing">Enigu inter 1–3 relay-ojn por uzi kiam serĉante enhavon aŭ etikedante uzantojn. Certigu ke viaj elektitaj relay-oj implementas NIP-50</string>
<string name="search_relays_not_found_examples">Bonaj opcioj estas:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">Bonaj opcioj estas:\n%1$s</string>
<string name="outbox_relays_title">Eligaj Relay-oj</string>
<string name="outbox_relays_not_found">Agordu viajn Publikajn Eligajn relay-ojn por afiŝi</string>
<string name="outbox_relays_not_found_description">Krei relay-liston specife dezajnitan por ricevi vian enhavon estas esenca por via Nostr-sperto kaj la sola maniero viaj sekvantoj povas trovi vin. </string>
+1 -1
View File
@@ -2038,7 +2038,7 @@
<string name="search_relays_not_found">Agordu viajn Serĉajn relay-ojn</string>
<string name="search_relays_not_found_description">Krei relay-liston specife dezajnitan por serĉo kaj uzanta etikedado plibonigos ĉi tiujn rezultojn.</string>
<string name="search_relays_not_found_editing">Enigu inter 1–3 relay-ojn por uzi kiam serĉante enhavon aŭ etikedante uzantojn. Certigu ke viaj elektitaj relay-oj implementas NIP-50</string>
<string name="search_relays_not_found_examples">Bonaj opcioj estas:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">Bonaj opcioj estas:\n%1$s</string>
<string name="outbox_relays_title">Eligaj Relay-oj</string>
<string name="outbox_relays_not_found">Agordu viajn Publikajn Eligajn relay-ojn por afiŝi</string>
<string name="outbox_relays_not_found_description">Krei relay-liston specife dezajnitan por ricevi vian enhavon estas esenca por via Nostr-sperto kaj la sola maniero viaj sekvantoj povas trovi vin. </string>
@@ -2201,7 +2201,7 @@
<string name="search_relays_not_found">Configura tus relés de búsqueda</string>
<string name="search_relays_not_found_description">La creación de una lista de relés específicamente diseñada para la búsqueda y el etiquetado de usuarios mejorará estos resultados.</string>
<string name="search_relays_not_found_editing">Inserta entre 1 y 3 relés para usarlos al buscar contenido o etiquetar usuarios. Asegúrate de que los relés que elijas implementen NIP-50.</string>
<string name="search_relays_not_found_examples">Algunas buenas opciones son: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">Algunas buenas opciones son: \n%1$s</string>
<string name="outbox_relays_title">Relays de salida</string>
<string name="outbox_relays_not_found">Configura tus relays de salida públicos para publicar</string>
<string name="outbox_relays_not_found_description">Crear una lista de relays diseñada específicamente para recibir tu contenido es fundamental para tu experiencia en Nostr y la única forma en que tus seguidores pueden encontrarte. </string>
@@ -2192,7 +2192,7 @@
<string name="search_relays_not_found">Configura tus relés de búsqueda</string>
<string name="search_relays_not_found_description">La creación de una lista de relés específicamente diseñada para la búsqueda y el etiquetado de usuarios mejorará estos resultados.</string>
<string name="search_relays_not_found_editing">Inserta entre 1 y 3 relés para usarlos al buscar contenido o etiquetar usuarios. Asegúrate de que los relés que elijas implementen NIP-50.</string>
<string name="search_relays_not_found_examples">Algunas buenas opciones son: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">Algunas buenas opciones son: \n%1$s</string>
<string name="outbox_relays_title">Relays de salida</string>
<string name="outbox_relays_not_found">Configura tus relays de salida públicos para publicar</string>
<string name="outbox_relays_not_found_description">Crear una lista de relays diseñada específicamente para recibir tu contenido es fundamental para tu experiencia en Nostr y la única forma en que tus seguidores pueden encontrarte. </string>
@@ -2192,7 +2192,7 @@
<string name="search_relays_not_found">Configura tus relés de búsqueda</string>
<string name="search_relays_not_found_description">La creación de una lista de relés específicamente diseñada para la búsqueda y el etiquetado de usuarios mejorará estos resultados.</string>
<string name="search_relays_not_found_editing">Inserta entre 1 y 3 relés para usarlos al buscar contenido o etiquetar usuarios. Asegúrate de que los relés que elijas implementen NIP-50.</string>
<string name="search_relays_not_found_examples">Algunas buenas opciones son: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">Algunas buenas opciones son: \n%1$s</string>
<string name="outbox_relays_title">Relays de salida</string>
<string name="outbox_relays_not_found">Configura tus relays de salida públicos para publicar</string>
<string name="outbox_relays_not_found_description">Crear una lista de relays diseñada específicamente para recibir tu contenido es fundamental para tu experiencia en Nostr y la única forma en que tus seguidores pueden encontrarte. </string>
+1 -1
View File
@@ -2528,7 +2528,7 @@
<string name="search_relays_not_found_editing">Inserta entre 1 y 3 relés para usarlos al buscar contenido o etiquetar usuarios. Asegúrate de que los relés que elijas implementen NIP-50.</string>
<string name="search_relays_not_found_examples">Algunas buenas opciones son: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">Algunas buenas opciones son: \n%1$s</string>
<string name="relay_settings">Configuración de relés</string>
@@ -2164,7 +2164,7 @@
<string name="search_relays_not_found">تنظیم رله های جستجو</string>
<string name="search_relays_not_found_description">ساخت لیستی از رله های مخصوص جستجو و تگ کردن کاربر باعث بهبود این نتایج خواهد شد.</string>
<string name="search_relays_not_found_editing">بین ۱-۳ رله بیافزایید تا هنگام جستجوی محتوا یا تگ کردن کاربران از آنها استفاده شود. حتما رله های انتخابی باید NIP-50 را پیاده‌سازی کرده باشند.</string>
<string name="search_relays_not_found_examples">انتخاب های مناسب: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">انتخاب های مناسب: \n%1$s</string>
<string name="outbox_relays_title">relay های صادره</string>
<string name="outbox_relays_not_found">relay های صادره عمومی خود را برای ارسال پست تنظیم کنید</string>
<string name="outbox_relays_not_found_description">ایجاد یک لیست relay که به طور خاص برای دریافت محتوای شما طراحی شده، برای تجربه Nostr شما حیاتی است و تنها راهی است که دنبال‌کنندگانتان می‌توانند شما را پیدا کنند.</string>
+1 -1
View File
@@ -637,7 +637,7 @@
<string name="search_relays_not_found">تنظیم رله های جستجو</string>
<string name="search_relays_not_found_description">ساخت لیستی از رله های مخصوص جستجو و تگ کردن کاربر باعث بهبود این نتایج خواهد شد.</string>
<string name="search_relays_not_found_editing">بین ۱-۳ رله بیافزایید تا هنگام جستجوی محتوا یا تگ کردن کاربران از آنها استفاده شود. حتما رله های انتخابی باید NIP-50 را پیاده‌سازی کرده باشند.</string>
<string name="search_relays_not_found_examples">انتخاب های مناسب: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">انتخاب های مناسب: \n%1$s</string>
<string name="relay_settings">تنظیمات رله</string>
<string name="public_home_section">رله های خانه/ صندوق خروجی عمومی</string>
<string name="public_home_section_explainer">این نوع از رله تمام محتوای شما را ذخریه می کند. امتیست پست های شما را اینجا می فرستد و دیگران از این رله ها برای یافتن محتوای شما استفاده می کنند. بین ۱-۳ رله وارد کنید. این رله ها ممکن است رله های شخصی، پولی، یا عمومی باشند.</string>
@@ -2133,7 +2133,7 @@
<string name="search_relays_not_found">Määritä hakurelayit</string>
<string name="search_relays_not_found_description">Hakuun ja käyttäjien merkitsemiseen tarkoitetun relay-listan luominen parantaa näitä tuloksia.</string>
<string name="search_relays_not_found_editing">Lisää 1–3 relayia käytettäväksi sisällön haussa tai käyttäjien merkitsemisessä. Varmista, että valitsemasi relayit toteuttavat NIP-50:n</string>
<string name="search_relays_not_found_examples">Hyviä vaihtoehtoja ovat:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">Hyviä vaihtoehtoja ovat:\n%1$s</string>
<string name="outbox_relays_title">Lähtevien relayit</string>
<string name="outbox_relays_not_found">Määritä julkiset lähtevien relayit julkaistaksesi</string>
<string name="outbox_relays_not_found_description">Sisällön vastaanottamiseen suunnitellun relay-listan luominen on keskeistä Nostr-kokemuksellesi ja ainoa tapa, jolla seuraajasi voivat löytää sinut. </string>
@@ -2073,7 +2073,7 @@
<string name="search_relays_not_found">Configurez vos relais de recherche</string>
<string name="search_relays_not_found_description">La création d\'une liste de relais spécialement conçue pour la recherche et l\'étiquetage utilisateur améliorera ces résultats.</string>
<string name="search_relays_not_found_editing">Insérez de 1 à 3 relais à utiliser lors de la recherche de contenu ou de l\'étiquetage utilisateur. Assurez-vous que les relais que vous avez choisis implémentent le NIP-50</string>
<string name="search_relays_not_found_examples">De bonnes options sont:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="search_relays_not_found_examples">De bonnes options sont:\n%1$s</string>
<string name="outbox_relays_title">Relais d\'envoi</string>
<string name="outbox_relays_not_found">Configurez vos relais d\'envoi pour publier</string>
<string name="outbox_relays_not_found_description">Créer une liste de relais spécialement conçue pour recevoir votre contenu est crucial pour votre expérience Nostr et la seule façon que vos abonnés puissent vous trouver. </string>

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