mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
Executable
+96
@@ -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
|
||||
@@ -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": [
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
}
|
||||
|
||||
+14
-2
@@ -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) }
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -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:"
|
||||
}
|
||||
|
||||
+6
@@ -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()
|
||||
|
||||
+2
-1
@@ -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)
|
||||
}
|
||||
|
||||
+109
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+161
@@ -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)
|
||||
}
|
||||
}
|
||||
+167
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+105
-18
@@ -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() }
|
||||
|
||||
+59
-16
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+108
-21
@@ -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"
|
||||
}
|
||||
+225
@@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+111
@@ -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() }
|
||||
}
|
||||
+137
@@ -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}" }
|
||||
}
|
||||
|
||||
+118
@@ -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
|
||||
|
||||
+55
@@ -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,
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+69
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+48
@@ -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 }
|
||||
}
|
||||
}
|
||||
+39
@@ -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
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -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
|
||||
|
||||
+6
-1
@@ -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),
|
||||
) {
|
||||
|
||||
+143
@@ -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
|
||||
}
|
||||
+35
-8
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -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,
|
||||
|
||||
+43
-6
@@ -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,
|
||||
|
||||
+60
-20
@@ -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() }
|
||||
}
|
||||
|
||||
+188
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+212
@@ -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)
|
||||
}
|
||||
}
|
||||
+247
@@ -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,
|
||||
)
|
||||
+374
@@ -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)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+335
@@ -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
|
||||
}
|
||||
}
|
||||
+204
@@ -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/*")) }
|
||||
}
|
||||
+163
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+319
@@ -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
|
||||
}
|
||||
}
|
||||
+131
@@ -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() }
|
||||
}
|
||||
+331
@@ -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
-8
@@ -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)
|
||||
|
||||
+14
-8
@@ -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))
|
||||
}
|
||||
|
||||
+14
-6
@@ -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))
|
||||
}
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+34
-13
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -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
|
||||
}
|
||||
+46
@@ -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() }
|
||||
}
|
||||
+41
@@ -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)
|
||||
}
|
||||
+17
-9
@@ -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()
|
||||
}
|
||||
|
||||
+35
-1
@@ -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()
|
||||
|
||||
+8
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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()
|
||||
}
|
||||
|
||||
+4
-1
@@ -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()
|
||||
|
||||
+4
-1
@@ -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()
|
||||
|
||||
+2
@@ -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,
|
||||
),
|
||||
|
||||
+6
-1
@@ -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)
|
||||
|
||||
+12
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user