mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #3976 from nrobi144/feat/desktop-live-media
feat(desktop): NIP-53 live streaming — consume & discover
This commit is contained in:
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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.commons.nip53LiveActivities
|
||||
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Pure, CLI-safe ranking + freshness logic for NIP-53 live activities, shared by Amethyst Android
|
||||
* and Amethyst Desktop so both platforms order live streams identically (no drift).
|
||||
*
|
||||
* Everything here operates on **primitives / pre-snapshotted values** rather than reading mutable
|
||||
* event or online-checker state. This is deliberate: the sort keys (status-after-online-check and
|
||||
* `current_participants`) can be mutated by background work mid-sort, and reading them lazily inside
|
||||
* a comparator makes the ordering unstable — TimSort then throws
|
||||
* *"Comparison method violates its general contract!"*. Callers must snapshot the keys into a
|
||||
* [LiveActivityRank] once, before sorting.
|
||||
*/
|
||||
object LiveActivitySorting {
|
||||
/** Status ordering used across the discover feed, the live bar and profile streams. */
|
||||
const val ORDER_LIVE = 2
|
||||
const val ORDER_PLANNED = 1
|
||||
const val ORDER_ENDED = 0
|
||||
|
||||
/** How recently a `status=live` 30311 must have been re-published to count for the live bar. */
|
||||
const val LIVE_BAR_FRESHNESS_SECONDS = 15 * 60L
|
||||
|
||||
/** Grace after a planned `starts` before it is treated as "overdue / never went live". */
|
||||
const val OVERDUE_PLANNED_GRACE_SECONDS = 60 * 60L
|
||||
|
||||
/**
|
||||
* Maps a live-activity status to a sort bucket. A `status=live` stream whose `.m3u8` is known to
|
||||
* be offline right now ([isOfflineNow]) is downgraded to [ORDER_ENDED] so dead streams sink.
|
||||
*/
|
||||
fun statusOrder(
|
||||
status: StatusTag.STATUS?,
|
||||
isOfflineNow: Boolean = false,
|
||||
): Int =
|
||||
when (status) {
|
||||
StatusTag.STATUS.LIVE -> if (isOfflineNow) ORDER_ENDED else ORDER_LIVE
|
||||
StatusTag.STATUS.PLANNED -> ORDER_PLANNED
|
||||
StatusTag.STATUS.ENDED -> ORDER_ENDED
|
||||
null -> ORDER_ENDED
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a stream should surface in the per-column "live now" bar: it is `status=live`, its
|
||||
* `.m3u8` is not known-offline, and the 30311 was (re)published within [freshnessSeconds]. A host
|
||||
* is expected to re-publish the 30311 continuously while live, so a stale one is a zombie.
|
||||
*/
|
||||
fun isLiveAndFresh(
|
||||
status: StatusTag.STATUS?,
|
||||
createdAt: Long,
|
||||
now: Long = TimeUtils.now(),
|
||||
isOfflineNow: Boolean = false,
|
||||
freshnessSeconds: Long = LIVE_BAR_FRESHNESS_SECONDS,
|
||||
): Boolean = status == StatusTag.STATUS.LIVE && !isOfflineNow && createdAt >= now - freshnessSeconds
|
||||
|
||||
/**
|
||||
* True when a `status=planned` stream's start time has passed by more than the grace window and
|
||||
* it never flipped to live — used to relabel / downrank stale "starts in…" cards.
|
||||
*/
|
||||
fun isOverduePlanned(
|
||||
status: StatusTag.STATUS?,
|
||||
startsAt: Long?,
|
||||
now: Long = TimeUtils.now(),
|
||||
graceSeconds: Long = OVERDUE_PLANNED_GRACE_SECONDS,
|
||||
): Boolean = status == StatusTag.STATUS.PLANNED && startsAt != null && startsAt < now - graceSeconds
|
||||
|
||||
/**
|
||||
* A pre-snapshotted set of comparator keys for one live activity. Build one per item **before**
|
||||
* sorting; never read live event/online state inside the comparator.
|
||||
*/
|
||||
data class LiveActivityRank(
|
||||
val statusOrder: Int,
|
||||
val followParticipants: Int,
|
||||
val totalParticipants: Int,
|
||||
val startOrCreated: Long,
|
||||
val idHex: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Descending comparator (best first): live > planned > ended, then more participating follows,
|
||||
* then more total participants, then more recent start/creation, then id for a stable tiebreak.
|
||||
*/
|
||||
val RANK_DESCENDING: Comparator<LiveActivityRank> =
|
||||
compareByDescending<LiveActivityRank> { it.statusOrder }
|
||||
.thenByDescending { it.followParticipants }
|
||||
.thenByDescending { it.totalParticipants }
|
||||
.thenByDescending { it.startOrCreated }
|
||||
.thenByDescending { it.idHex }
|
||||
|
||||
/**
|
||||
* Snapshots each item's rank **once** via [rankOf], then sorts. Because the keys are captured up
|
||||
* front, [rankOf] may read volatile state (online cache, `current_participants`) safely — the
|
||||
* comparator only ever sees the immutable snapshot, so TimSort's contract holds.
|
||||
*/
|
||||
fun <T> sortDescending(
|
||||
items: Collection<T>,
|
||||
rankOf: (T) -> LiveActivityRank,
|
||||
): List<T> {
|
||||
val snapshot = items.associateWith(rankOf)
|
||||
return items.sortedWith { a, b -> RANK_DESCENDING.compare(snapshot.getValue(a), snapshot.getValue(b)) }
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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.commons.nip53LiveActivities
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivitySorting.LiveActivityRank
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class LiveActivitySortingTest {
|
||||
private val now = 1_000_000L
|
||||
|
||||
@Test
|
||||
fun statusOrderRanksLiveOverPlannedOverEnded() {
|
||||
assertEquals(LiveActivitySorting.ORDER_LIVE, LiveActivitySorting.statusOrder(StatusTag.STATUS.LIVE))
|
||||
assertEquals(LiveActivitySorting.ORDER_PLANNED, LiveActivitySorting.statusOrder(StatusTag.STATUS.PLANNED))
|
||||
assertEquals(LiveActivitySorting.ORDER_ENDED, LiveActivitySorting.statusOrder(StatusTag.STATUS.ENDED))
|
||||
assertEquals(LiveActivitySorting.ORDER_ENDED, LiveActivitySorting.statusOrder(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineLiveIsDowngradedToEnded() {
|
||||
assertEquals(
|
||||
LiveActivitySorting.ORDER_ENDED,
|
||||
LiveActivitySorting.statusOrder(StatusTag.STATUS.LIVE, isOfflineNow = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isLiveAndFreshRespectsFreshnessWindow() {
|
||||
// published just now -> fresh
|
||||
assertTrue(
|
||||
LiveActivitySorting.isLiveAndFresh(StatusTag.STATUS.LIVE, createdAt = now, now = now),
|
||||
)
|
||||
// published 20 min ago -> stale (default 15-min window)
|
||||
assertFalse(
|
||||
LiveActivitySorting.isLiveAndFresh(StatusTag.STATUS.LIVE, createdAt = now - 20 * 60, now = now),
|
||||
)
|
||||
// fresh but offline -> excluded
|
||||
assertFalse(
|
||||
LiveActivitySorting.isLiveAndFresh(StatusTag.STATUS.LIVE, createdAt = now, now = now, isOfflineNow = true),
|
||||
)
|
||||
// planned is never "live and fresh"
|
||||
assertFalse(
|
||||
LiveActivitySorting.isLiveAndFresh(StatusTag.STATUS.PLANNED, createdAt = now, now = now),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overduePlannedDetectedAfterGrace() {
|
||||
// starts 2h ago, still planned -> overdue
|
||||
assertTrue(
|
||||
LiveActivitySorting.isOverduePlanned(StatusTag.STATUS.PLANNED, startsAt = now - 2 * 60 * 60, now = now),
|
||||
)
|
||||
// starts in the future -> not overdue
|
||||
assertFalse(
|
||||
LiveActivitySorting.isOverduePlanned(StatusTag.STATUS.PLANNED, startsAt = now + 60 * 60, now = now),
|
||||
)
|
||||
// no start time -> not overdue
|
||||
assertFalse(
|
||||
LiveActivitySorting.isOverduePlanned(StatusTag.STATUS.PLANNED, startsAt = null, now = now),
|
||||
)
|
||||
// live is never "overdue planned"
|
||||
assertFalse(
|
||||
LiveActivitySorting.isOverduePlanned(StatusTag.STATUS.LIVE, startsAt = now - 2 * 60 * 60, now = now),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sortOrdersByStatusThenFollowsThenTotalThenTime() {
|
||||
val endedFresh = rank(LiveActivitySorting.ORDER_ENDED, follows = 5, total = 5, time = now, id = "a")
|
||||
val liveFewFollows = rank(LiveActivitySorting.ORDER_LIVE, follows = 1, total = 1, time = now - 100, id = "b")
|
||||
val liveManyFollows = rank(LiveActivitySorting.ORDER_LIVE, follows = 3, total = 3, time = now - 200, id = "c")
|
||||
val planned = rank(LiveActivitySorting.ORDER_PLANNED, follows = 9, total = 9, time = now, id = "d")
|
||||
|
||||
val items = listOf(endedFresh, liveFewFollows, planned, liveManyFollows)
|
||||
val sorted = LiveActivitySorting.sortDescending(items) { it }
|
||||
|
||||
// live (most follows) > live (fewer) > planned > ended
|
||||
assertEquals(listOf(liveManyFollows, liveFewFollows, planned, endedFresh), sorted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sortBreaksTiesByTotalThenTimeThenId() {
|
||||
val a = rank(LiveActivitySorting.ORDER_LIVE, follows = 2, total = 10, time = now, id = "aaa")
|
||||
val b = rank(LiveActivitySorting.ORDER_LIVE, follows = 2, total = 20, time = now, id = "bbb")
|
||||
val c = rank(LiveActivitySorting.ORDER_LIVE, follows = 2, total = 20, time = now + 50, id = "ccc")
|
||||
|
||||
val sorted = LiveActivitySorting.sortDescending(listOf(a, b, c)) { it }
|
||||
// higher total first (b,c over a); newer time breaks b vs c
|
||||
assertEquals(listOf(c, b, a), sorted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sortIsStableUnderConcurrentKeyMutation() {
|
||||
// Simulate a background online-check flipping a stream's status mid-sort: rankOf reads a
|
||||
// volatile source, but sortDescending snapshots once, so no TimSort contract violation.
|
||||
val ids = (0 until 200).map { it.toString().padStart(3, '0') }
|
||||
var flips = 0
|
||||
val sorted =
|
||||
LiveActivitySorting.sortDescending(ids) { id ->
|
||||
// Alternate the status bucket every read; if the comparator read this lazily it would
|
||||
// be inconsistent and throw. Snapshotting protects us.
|
||||
flips++
|
||||
val order = if (flips % 2 == 0) LiveActivitySorting.ORDER_LIVE else LiveActivitySorting.ORDER_ENDED
|
||||
LiveActivityRank(order, 0, 0, now, id)
|
||||
}
|
||||
assertEquals(ids.size, sorted.size)
|
||||
}
|
||||
|
||||
private fun rank(
|
||||
statusOrder: Int,
|
||||
follows: Int,
|
||||
total: Int,
|
||||
time: Long,
|
||||
id: String,
|
||||
) = LiveActivityRank(statusOrder, follows, total, time, id)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
# Manual Testing Sheet — Desktop Live Media (NIP-53) v1
|
||||
|
||||
**Plan:** `docs/plans/2026-08-20-feat-desktop-live-media-plan.md`
|
||||
**Branch / worktree:** `feat/desktop-live-media` · `.claude/worktrees/feat-desktop-live-media`
|
||||
**Run:** `./gradlew :desktopApp:run`
|
||||
|
||||
Acceptance test for the whole v1 feature. Each section is tagged with its current build state so you
|
||||
know what will actually work today:
|
||||
|
||||
- **🟢 LIVE** — built & compiling; test it now.
|
||||
- **🟡 PARTIAL** — built but with a known gap (called out inline).
|
||||
- **⚪ PENDING** — not built yet; skip until its commit lands.
|
||||
|
||||
**Legend for results:** ✅ pass · ❌ fail (write what happened) · ⏭️ skipped/blocked
|
||||
|
||||
---
|
||||
|
||||
## Setup (do once)
|
||||
- [ ] Open **<https://zap.stream>** in a browser — this shows who is live on Nostr right now with real
|
||||
kind-30311 streams + kind-1311 chat. Keep it handy as your source of truth.
|
||||
- [ ] Log into Amethyst Desktop with an account that **follows ≥1 host who streams** (follow a couple of
|
||||
the zap.stream front-page hosts if not).
|
||||
- [ ] Confirm relays are connected (no persistent "offline" banner).
|
||||
- [ ] (For later zap tests) connect an NWC wallet under **Wallet**.
|
||||
|
||||
> **Tip:** live streams come and go. If a section says "nobody is live," check zap.stream — if the front
|
||||
> page is also empty, wait for a stream to start rather than recording a failure.
|
||||
|
||||
---
|
||||
|
||||
## 1. Discover → "Live now" 🟢 LIVE
|
||||
|
||||
Open the **Discover** destination. Scroll to the **LIVE NOW** section (below the featured pack hero).
|
||||
|
||||
| # | Step | Expected | Result |
|
||||
|---|------|----------|--------|
|
||||
| 1.1 | Open Discover with ≥1 stream live on the network | A **LIVE NOW** section appears with a red dot header + a search box + a grid of cards | |
|
||||
| 1.2 | Look at card order | **Live** streams first, then **planned** ("starts in…"), then ended; within live, streams where **more of your follows** participate rank higher, then higher **viewer count** | |
|
||||
| 1.3 | Inspect a live card | Shows thumbnail image, a red **LIVE** badge, the **title**, the **host** name, and **"N watching"** when the host reports a viewer count | |
|
||||
| 1.4 | Inspect a planned card | Shows a **"in 2h / in 30m / SCHEDULED"** badge instead of LIVE | |
|
||||
| 1.5 | Type a host's name in the search box | Grid filters live to matching streams as you type | |
|
||||
| 1.6 | Type part of a stream **title** | Filters to matching titles | |
|
||||
| 1.7 | Type a **hashtag** that a live stream uses (e.g. `bitcoin`) | Filters to streams tagged with it | |
|
||||
| 1.8 | Type gibberish (`zzzzz`) | Shows **"No live streams match "zzzzz.""** (grid empty, section still visible) | |
|
||||
| 1.9 | Clear the search | Full ranked grid returns | |
|
||||
| 1.10 | Open Discover when **nothing** is live network-wide | The LIVE NOW section is **absent** (not an empty box) | |
|
||||
| 1.11 | **Known gap:** click a card | Nothing happens yet — the watch screen isn't wired (see §3). Not a bug today. | |
|
||||
|
||||
---
|
||||
|
||||
## 2. Per-column "live now" bar 🟢 LIVE
|
||||
|
||||
| # | Step | Expected | Result |
|
||||
|---|------|----------|--------|
|
||||
| 2.1 | Open a **Following** feed column while a **followed** host is live | A compact pinned bar sits at the **top of that column**, above the feed: red dot + **"<host> is live"** | |
|
||||
| 2.2 | Have **2+** followed hosts live at once | The bar shows the **most-watched** one, plus a **"+N live ›"** link | |
|
||||
| 2.3 | Click **"+N live ›"** | A dropdown lists all currently-live streams in scope; each entry is clickable | |
|
||||
| 2.4 | Open a **Global** feed column | The bar reflects the **global** live set (typically different/larger than Following) | |
|
||||
| 2.5 | Scroll the feed | The bar stays **pinned** at the top (doesn't scroll away) and the feed scrolls smoothly beneath it | |
|
||||
| 2.6 | Open a column where **nobody in scope** is live | **No bar** is shown (Following bar hidden when no followed host is live) | |
|
||||
| 2.7 | Open a **hashtag / list / search / notifications** column | **No bar** (by design — v1 scopes the bar to Following + Global only) | |
|
||||
| 2.8 | **Known gap:** click the bar / a dropdown entry | No-op today (watch screen pending, §3) | |
|
||||
|
||||
---
|
||||
|
||||
## 3. Watch screen — player + chat + zap ⚪ PENDING
|
||||
|
||||
*Not built yet. These are the target checks for when the watch commit lands; skip for now.*
|
||||
|
||||
### Open / layout
|
||||
- [ ] Clicking a live card or the bar opens a **full-window** watch screen: video **left**, chat **right**, host/metadata below.
|
||||
- [ ] Header shows title, host (avatar + name), participant roles, viewer count, and a **LIVE** pill.
|
||||
- [ ] Closing returns to the prior view and **stops playback**.
|
||||
|
||||
### Video (live)
|
||||
- [ ] The live stream plays (audio + video).
|
||||
- [ ] **No seek bar** in live mode (a "● LIVE" pill instead).
|
||||
- [ ] Play/pause, volume, fullscreen work.
|
||||
- [ ] Kill the source briefly → **"Reconnecting…"** (not a frozen frame) → recovers, or an **offline + Retry** state if it stays down.
|
||||
- [ ] Opening an already-offline stream → offline placeholder, never an endless spinner.
|
||||
|
||||
### Video (VOD / recording)
|
||||
- [ ] An **ended** stream with a recording plays the recording with a **normal seekable** bar.
|
||||
- [ ] An ended stream with **no recording** shows "Stream ended — no recording" (non-playable).
|
||||
|
||||
### Chat (read)
|
||||
- [ ] Kind-1311 messages for this stream appear, newest at bottom.
|
||||
- [ ] Auto-scrolls to newest **only while at the bottom**; scrolling up shows a **"↓ N new"** pill.
|
||||
- [ ] Hovering chat pauses auto-scroll; leaving resumes.
|
||||
- [ ] **Muted/blocked** users' messages don't appear; muting mid-stream hides existing ones immediately.
|
||||
- [ ] A busy stream stays smooth (messages may batch slightly).
|
||||
|
||||
### Chat (post)
|
||||
- [ ] Sending posts a kind-1311 with the correct stream `a` tag (verify it shows on zap.stream).
|
||||
- [ ] Composer is **disabled with a reason** when logged out / no relays / stream ended (draft preserved).
|
||||
|
||||
### Zap
|
||||
- [ ] **Zap** the stream → amount dialog → sends via NWC; receipt attributed to the 30311.
|
||||
- [ ] Zap **disabled with reason** when the host has no lightning address or no NWC wallet is connected.
|
||||
- [ ] *(Per-message chat zap + top-zappers leaderboard are v1.5 — not expected.)*
|
||||
|
||||
### Lifecycle / re-entry
|
||||
- [ ] Host edits title/participants mid-stream → header updates live.
|
||||
- [ ] Stream ends mid-watch → "ended" banner, composer disabled, "Play recording" offered; window does **not** auto-close.
|
||||
- [ ] Opening a **second** stream tears down the first cleanly (no double audio, no leaked chat).
|
||||
- [ ] Opening via a direct **naddr/nevent** link resolves + loads; unknown → clear message, no hang.
|
||||
|
||||
---
|
||||
|
||||
## 4. Profile "Streams" tab ⚪ PENDING
|
||||
- [ ] A streaming host's **profile** has a **Streams** tab listing their live + past streams.
|
||||
- [ ] A live entry opens the watch screen; a past one with a recording plays VOD.
|
||||
|
||||
## 5. Planned streams 🟡 PARTIAL
|
||||
- [ ] Planned streams show a **"starts in…"** badge in the Discover grid. 🟢 (works now)
|
||||
- [ ] A planned stream whose start passed long ago and never went live is relabeled/downranked. ⚪ (pending online/overdue wiring)
|
||||
- [ ] *(No "Remind me" in v1 — badge only, by design.)*
|
||||
|
||||
## 6. Online-probe / dead streams ⚪ PENDING
|
||||
- [ ] A `status=live` stream whose `.m3u8` is actually dead is **not** shown as live (sinks/hidden).
|
||||
*Today all `status=live` are treated as online — this downgrade is not wired yet.*
|
||||
|
||||
---
|
||||
|
||||
## 7. Cross-cutting / security & regression 🟡 PARTIAL (mostly pending)
|
||||
- [ ] ⚪ App only plays `https` stream URLs; `file://`/`smb://`/custom-scheme URLs are refused (incl. the "open in default player" fallback).
|
||||
- [ ] ⚪ No drive-by HEAD probing of every cached stream URL (probe only what's needed for visible cards).
|
||||
- [ ] ⚪ Chat messages with bidirectional/control unicode render without reordering surrounding UI.
|
||||
- [ ] 🟢 Existing feed video (NowPlayingBar) still works; existing feeds/Discover unaffected by the new section.
|
||||
- [ ] 🟢 `./gradlew :commons:jvmTest --tests "*.LiveActivitySortingTest"` passes.
|
||||
- [ ] ⚪ Full build green: `./gradlew :desktopApp:compileKotlin :amethyst:compileDebugKotlin` + `spotlessApply` clean.
|
||||
|
||||
---
|
||||
|
||||
## Platform notes
|
||||
- **macOS** (AVFoundation): primary target for the video tests.
|
||||
- **Linux** (GStreamer): must be installed at runtime; live-HLS reconnection differs — spot-check when watch lands.
|
||||
- **Windows** (Media Foundation): weakest native live HLS — the stall watchdog/reconnect must carry it.
|
||||
|
||||
## Known v1 limitations (by design — not bugs)
|
||||
No broadcasting; no audio rooms / raids / clips; no per-message chat zap or leaderboard (v1.5); no OS
|
||||
notifications / "Remind me" (badge only); live bar only on Following + Global columns.
|
||||
|
||||
---
|
||||
|
||||
### Quick "test right now" path (today's build)
|
||||
1. `./gradlew :desktopApp:run`, log in, ensure ≥1 stream is live (check zap.stream).
|
||||
2. **Discover → LIVE NOW**: see the grid, confirm ranking (§1.2), try search (§1.5–1.9).
|
||||
3. **Home → Following** and **Global**: confirm the pinned live bar + "+N live ›" (§2).
|
||||
4. Everything in §3–§7 marked ⚪/🟡-pending is expected **not** to work yet — that's the next build.
|
||||
@@ -2359,6 +2359,23 @@ fun MainContent(
|
||||
com.vitorpamplona.amethyst.desktop.ui.media
|
||||
.GlobalFullscreenOverlay()
|
||||
|
||||
// Full-window NIP-53 live watch overlay (player + chat), opened from any live surface
|
||||
// via LiveWatchController.
|
||||
val watchAddress by com.vitorpamplona.amethyst.desktop.ui.live.LiveWatchController.current
|
||||
.collectAsState()
|
||||
watchAddress?.let { addr ->
|
||||
com.vitorpamplona.amethyst.desktop.ui.live.LiveWatchScreen(
|
||||
address = addr,
|
||||
cache = localCache,
|
||||
relayManager = relayManager,
|
||||
account = account,
|
||||
onClose = {
|
||||
com.vitorpamplona.amethyst.desktop.ui.live.LiveWatchController
|
||||
.close()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Snackbar for zap feedback
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
|
||||
Vendored
+121
-4
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.UserContext
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollTallyPolicy
|
||||
import com.vitorpamplona.amethyst.commons.service.nwc.NwcPaymentTracker
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
@@ -57,6 +58,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEv
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
@@ -65,6 +68,7 @@ import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
@@ -90,6 +94,16 @@ class DesktopLocalCache : ICacheProvider {
|
||||
val users = LargeSoftCache<HexKey, User>()
|
||||
val notes = LargeSoftCache<HexKey, Note>()
|
||||
val addressableNotes = LargeSoftCache<String, AddressableNote>()
|
||||
|
||||
/**
|
||||
* NIP-53 live-stream channels (kind 30311), keyed by address value. This is the FIRST channel
|
||||
* cache on Desktop (the base app has none — [getAnyChannel] historically returned null). It is a
|
||||
* strong-ref [LargeCache] (not soft) because a channel owns its live chat (kind 1311) notes and
|
||||
* must survive GC while a stream is on-screen; chat growth is bounded instead by
|
||||
* [LiveActivitiesChannel.pruneOldMessages] (cap 500), triggered from [consumeLiveActivityChat].
|
||||
*/
|
||||
val liveChatChannels = LargeCache<String, LiveActivitiesChannel>()
|
||||
|
||||
private val deletedEvents = ConcurrentHashMap.newKeySet<HexKey>()
|
||||
|
||||
/** NIP-hints index accumulated from consumed events (event/address/pubkey → relay). */
|
||||
@@ -130,6 +144,8 @@ class DesktopLocalCache : ICacheProvider {
|
||||
val metadataVersion: StateFlow<Long> = _metadataVersion.asStateFlow()
|
||||
|
||||
companion object {
|
||||
/** Cap on retained kind-1311 chat messages per live channel (matches [Channel.pruneOldMessages]). */
|
||||
const val MAX_CHAT_MESSAGES_PER_CHANNEL = 500
|
||||
}
|
||||
|
||||
/** Index of notes by author pubkey — for fast metadata invalidation */
|
||||
@@ -279,8 +295,13 @@ class DesktopLocalCache : ICacheProvider {
|
||||
): Boolean {
|
||||
if (!wasVerified && !justVerify(event)) return false
|
||||
val consumed = route(event, relay)
|
||||
// Write-through to local store, but skip if event came from local store (hydration)
|
||||
if (consumed && relay != com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore.LOCAL_RELAY_URL) {
|
||||
// Write-through to local store, but skip if event came from local store (hydration). Live
|
||||
// chat (kind 1311) is deliberately NOT persisted: a busy stream's chat is unbounded and
|
||||
// replaying it on next launch is pointless (the stream is over) — only the 30311s hydrate.
|
||||
if (consumed &&
|
||||
relay != com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore.LOCAL_RELAY_URL &&
|
||||
event !is LiveActivitiesChatMessageEvent
|
||||
) {
|
||||
localRelayStore?.enqueue(event)
|
||||
}
|
||||
return consumed
|
||||
@@ -364,6 +385,14 @@ class DesktopLocalCache : ICacheProvider {
|
||||
consumePollResponse(event, relay)
|
||||
}
|
||||
|
||||
is LiveActivitiesEvent -> {
|
||||
consumeLiveActivity(event, relay)
|
||||
}
|
||||
|
||||
is LiveActivitiesChatMessageEvent -> {
|
||||
consumeLiveActivityChat(event, relay)
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
@@ -797,6 +826,88 @@ class DesktopLocalCache : ICacheProvider {
|
||||
return true
|
||||
}
|
||||
|
||||
// ----- NIP-53 live activities (kind 30311 streams + kind 1311 chat) -----
|
||||
|
||||
/**
|
||||
* Bumped whenever a live-stream (30311) is added/updated so the Lives discovery grid and the
|
||||
* per-column "live now" bar can recompute their snapshot. Chat (1311) does NOT bump this — chat
|
||||
* updates flow through the channel's own notes flow, and a new message never changes stream
|
||||
* ranking, so bumping here would cause needless full-grid recomputes on busy streams.
|
||||
*/
|
||||
private val _liveActivityVersion = MutableStateFlow(0L)
|
||||
val liveActivityVersion: StateFlow<Long> = _liveActivityVersion.asStateFlow()
|
||||
|
||||
fun getOrCreateLiveActivityChannel(address: Address): LiveActivitiesChannel =
|
||||
liveChatChannels.getOrCreate(address.toValue()) {
|
||||
LiveActivitiesChannel(address)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a kind 30311 live streaming event. Replaceable, so the newest per-address copy wins
|
||||
* (stored in [addressableNotes] like other addressables) and is attached to the address's
|
||||
* [LiveActivitiesChannel]. Returns true so it write-throughs to the local relay store (30311s are
|
||||
* cheap and bounded — hydrating them on next launch shows the last-known streams instantly).
|
||||
*/
|
||||
private fun consumeLiveActivity(
|
||||
event: LiveActivitiesEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
): Boolean {
|
||||
val address = event.address()
|
||||
val addressableNote = getOrCreateAddressableNote(address)
|
||||
|
||||
val existing = addressableNote.event
|
||||
if (existing != null && existing.createdAt >= event.createdAt) return false
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
addressableNote.loadEvent(event, author, emptyList())
|
||||
relay?.let { addressableNote.addRelay(it) }
|
||||
|
||||
val channel = getOrCreateLiveActivityChannel(address)
|
||||
channel.updateChannelInfo(author, event, addressableNote)
|
||||
|
||||
_liveActivityVersion.value++
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a kind 1311 live chat message and attaches it to its stream's channel (by the root
|
||||
* `a` tag). The channel's chat is capped at 500 newest messages via [pruneOldMessages] so a busy
|
||||
* stream can't grow memory unbounded. Unlike 30311, chat is NOT persisted to the local relay
|
||||
* store (see [consume]) to avoid unbounded chat replay on next launch.
|
||||
*/
|
||||
private fun consumeLiveActivityChat(
|
||||
event: LiveActivitiesChatMessageEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
if (note.event != null) return false
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
note.loadEvent(event, author, emptyList())
|
||||
trackNoteAuthor(note, event.pubKey)
|
||||
relay?.let { note.addRelay(it) }
|
||||
|
||||
val activityAddress = event.activityAddress() ?: return true
|
||||
val channel = getOrCreateLiveActivityChannel(activityAddress)
|
||||
channel.addNote(note, relay)
|
||||
if (channel.notes.size() > MAX_CHAT_MESSAGES_PER_CHANNEL) {
|
||||
channel.pruneOldMessages()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot every known live-stream channel that currently carries a 30311. Driven by
|
||||
* [liveActivityVersion] for reactive recomputation (mirrors [snapshotFollowPacks]).
|
||||
*/
|
||||
fun snapshotLiveActivities(): List<LiveActivitiesChannel> {
|
||||
val out = mutableListOf<LiveActivitiesChannel>()
|
||||
liveChatChannels.forEach { _, channel ->
|
||||
if (channel.info != null) out.add(channel)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot all 39089 (FollowListEvent) addressable notes currently in cache.
|
||||
* Use [followPackVersion] to drive reactive recomputation.
|
||||
@@ -921,8 +1032,13 @@ class DesktopLocalCache : ICacheProvider {
|
||||
// ----- Channel operations -----
|
||||
|
||||
override fun getAnyChannel(note: Note): Channel? {
|
||||
// Desktop doesn't support channels yet
|
||||
return null
|
||||
// Only NIP-53 live channels exist on Desktop today. Resolve a 1311 chat message or a 30311
|
||||
// stream note back to its channel; everything else has no channel.
|
||||
return when (val event = note.event) {
|
||||
is LiveActivitiesChatMessageEvent -> event.activityAddress()?.let { liveChatChannels.get(it.toValue()) }
|
||||
is LiveActivitiesEvent -> liveChatChannels.get(event.address().toValue())
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Deletion tracking -----
|
||||
@@ -1015,6 +1131,7 @@ class DesktopLocalCache : ICacheProvider {
|
||||
users.clear()
|
||||
notes.clear()
|
||||
addressableNotes.clear()
|
||||
liveChatChannels.clear()
|
||||
deletedEvents.clear()
|
||||
_followedUsers.value = emptySet()
|
||||
followerCounts.clear()
|
||||
|
||||
+14
@@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.desktop.followpacks.subscribeMetadataFor
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||
import com.vitorpamplona.amethyst.desktop.ui.live.LivesSection
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.Toolkit
|
||||
@@ -86,6 +87,10 @@ fun DiscoverScreen(
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
onNavigateToThread: (String) -> Unit,
|
||||
@Suppress("UNUSED_PARAMETER") onZapFeedback: (ZapFeedback) -> Unit,
|
||||
onOpenLive: (String) -> Unit = {
|
||||
com.vitorpamplona.amethyst.desktop.ui.live.LiveWatchController
|
||||
.open(it)
|
||||
},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val featured by state.featuredPack.collectAsState()
|
||||
@@ -193,6 +198,15 @@ fun DiscoverScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Live now (NIP-53) ----
|
||||
Spacer(Modifier.height(24.dp))
|
||||
LivesSection(
|
||||
cache = cache,
|
||||
relayManager = relayManager,
|
||||
followSet = followedAuthors.authors,
|
||||
onOpenLive = onOpenLive,
|
||||
)
|
||||
|
||||
// ---- Topic chips ----
|
||||
val chipTags = remember(current) { current?.let { state.hashtagsFor(it) } ?: emptyList() }
|
||||
if (chipTags.isNotEmpty()) {
|
||||
|
||||
+26
-11
@@ -73,6 +73,11 @@ object GlobalMediaPlayer {
|
||||
|
||||
@Volatile private var audioPlayer: VideoPlayerState? = null
|
||||
|
||||
// In-flight `openUri` for the current video track. Cancelled before a new open so two rapid
|
||||
// track switches (e.g. opening one live stream while another is still loading) can't interleave
|
||||
// openUri calls on the single shared engine — that race left the player stuck / black.
|
||||
@Volatile private var videoOpenJob: Job? = null
|
||||
|
||||
private val initLock = Any()
|
||||
|
||||
/**
|
||||
@@ -120,12 +125,18 @@ object GlobalMediaPlayer {
|
||||
return
|
||||
}
|
||||
|
||||
if (current.url == url) {
|
||||
// Reuse the engine only when it's already on this URL AND healthy. If the last attempt
|
||||
// errored (a common transient for live HLS — a dead segment, a 403, a stream that just
|
||||
// went live), fall through and re-open instead of leaving a stuck/errored surface.
|
||||
if (current.url == url && current.errorReason == null) {
|
||||
println("GlobalMediaPlayer.playVideo REUSE url=$url isPlaying=${current.isPlaying}")
|
||||
if (seekPosition > 0f) player.seekTo(seekPosition * 1000f)
|
||||
if (!current.isPlaying) player.play()
|
||||
return
|
||||
}
|
||||
|
||||
println("GlobalMediaPlayer.playVideo OPEN url=$url (was=${current.url} err=${current.errorReason})")
|
||||
|
||||
// Reset engine volume to match the UI's default for the new track. The
|
||||
// kdroidFilter player retains `volume` across openUri calls, so a mute
|
||||
// on the prior track would otherwise carry over while the UI shows the
|
||||
@@ -134,17 +145,20 @@ object GlobalMediaPlayer {
|
||||
|
||||
_videoState.value = MediaPlaybackState(url = url, type = MediaType.VIDEO, isBuffering = true)
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
player.openUri(url)
|
||||
// openUri auto-plays per InitialPlayerState.PLAY default. For an
|
||||
// initial seek we wait for the first hasMedia=true emission then
|
||||
// stop collecting (Flow.first terminates the collector cleanly,
|
||||
// unlike `return@collect` which only exits the lambda).
|
||||
if (seekPosition > 0f) {
|
||||
snapshotFlow { player.hasMedia }.first { it }
|
||||
player.seekTo(seekPosition * 1000f)
|
||||
// Cancel any in-flight open so a rapid switch can't interleave openUri on the shared engine.
|
||||
videoOpenJob?.cancel()
|
||||
videoOpenJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
player.openUri(url)
|
||||
// openUri auto-plays per InitialPlayerState.PLAY default. For an
|
||||
// initial seek we wait for the first hasMedia=true emission then
|
||||
// stop collecting (Flow.first terminates the collector cleanly,
|
||||
// unlike `return@collect` which only exits the lambda).
|
||||
if (seekPosition > 0f) {
|
||||
snapshotFlow { player.hasMedia }.first { it }
|
||||
player.seekTo(seekPosition * 1000f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun playAudio(url: String) {
|
||||
@@ -236,6 +250,7 @@ object GlobalMediaPlayer {
|
||||
}
|
||||
|
||||
fun stopVideo() {
|
||||
videoOpenJob?.cancel()
|
||||
videoPlayer?.stop()
|
||||
_videoState.value = MediaPlaybackState()
|
||||
_isFullscreen.value = false
|
||||
|
||||
+38
@@ -73,6 +73,44 @@ fun createFollowingFeedSubscription(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subscription config for NIP-53 live streams (kind 30311). Pass [authors] to scope to a
|
||||
* set of hosts (e.g. the user's follows); null fetches globally for discovery.
|
||||
*/
|
||||
fun createLiveActivitiesSubscription(
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
authors: List<String>? = null,
|
||||
limit: Int = 100,
|
||||
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||
): SubscriptionConfig =
|
||||
SubscriptionConfig(
|
||||
subId = generateSubId("live-activities"),
|
||||
filters = listOf(FilterBuilders.liveActivities(authors = authors, limit = limit)),
|
||||
relays = relays,
|
||||
onEvent = onEvent,
|
||||
onEose = onEose,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a subscription config for the live chat (kind 1311) of a single stream, keyed by the
|
||||
* stream's 30311 address (`kind:pubkey:d`).
|
||||
*/
|
||||
fun createLiveChatSubscription(
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
streamAddress: String,
|
||||
limit: Int = 200,
|
||||
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||
): SubscriptionConfig =
|
||||
SubscriptionConfig(
|
||||
subId = generateSubId("live-chat-${streamAddress.take(24)}"),
|
||||
filters = listOf(FilterBuilders.liveActivityChat(streamAddress, limit = limit)),
|
||||
relays = relays,
|
||||
onEvent = onEvent,
|
||||
onEose = onEose,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a subscription config for contact list (kind 3).
|
||||
*/
|
||||
|
||||
+24
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.desktop.subscriptions
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
|
||||
@@ -31,6 +33,28 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
object FilterBuilders {
|
||||
private val FEED_KINDS = listOf(1, 6, 16, 1068) // TextNoteEvent, RepostEvent, GenericRepostEvent, PollEvent
|
||||
|
||||
/** Filter for NIP-53 live streaming events (kind 30311), optionally scoped to specific hosts. */
|
||||
fun liveActivities(
|
||||
authors: List<String>? = null,
|
||||
limit: Int = 100,
|
||||
): Filter =
|
||||
Filter(
|
||||
kinds = listOf(LiveActivitiesEvent.KIND),
|
||||
authors = authors,
|
||||
limit = limit,
|
||||
)
|
||||
|
||||
/** Filter for NIP-53 live chat messages (kind 1311) tied to a stream by its root `a` tag. */
|
||||
fun liveActivityChat(
|
||||
streamAddress: String,
|
||||
limit: Int = 200,
|
||||
): Filter =
|
||||
Filter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
|
||||
tags = mapOf("a" to listOf(streamAddress)),
|
||||
limit = limit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for text notes (kind 1) from all authors.
|
||||
*
|
||||
|
||||
+7
-1
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Represents an active relay subscription that can be unsubscribed.
|
||||
@@ -122,4 +123,9 @@ fun rememberSubscription(
|
||||
/**
|
||||
* Generates a unique subscription ID with timestamp.
|
||||
*/
|
||||
fun generateSubId(prefix: String): String = "$prefix-${System.currentTimeMillis()}"
|
||||
private val subIdCounter = AtomicLong(0)
|
||||
|
||||
// Append a monotonic per-process counter so two subscriptions created in the same millisecond
|
||||
// (e.g. the live bar + the Discover Lives section on the same frame) can't collide on subId — a
|
||||
// collision would make one's unsubscribe tear down the other's REQ.
|
||||
fun generateSubId(prefix: String): String = "$prefix-${System.currentTimeMillis()}-${subIdCounter.incrementAndGet()}"
|
||||
|
||||
@@ -137,6 +137,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscr
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.live.LiveNowBar
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.DesktopPollCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||
@@ -580,6 +581,10 @@ fun FeedScreen(
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
onNavigateToRelays: () -> Unit = {},
|
||||
onSearchClick: () -> Unit = {},
|
||||
onOpenLive: (String) -> Unit = {
|
||||
com.vitorpamplona.amethyst.desktop.ui.live.LiveWatchController
|
||||
.open(it)
|
||||
},
|
||||
) {
|
||||
val feedSearchActiveState = com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive.current
|
||||
var searchActive by feedSearchActiveState
|
||||
@@ -968,6 +973,20 @@ fun FeedScreen(
|
||||
// Reserve space for the header card that floats above the feed.
|
||||
Spacer(Modifier.height(headerSpacerHeight))
|
||||
|
||||
// Pinned "live now" bar, scoped to this column's audience (Following / Global only).
|
||||
if (feedMode == FeedMode.FOLLOWING || feedMode == FeedMode.GLOBAL) {
|
||||
val liveBarPadding = LocalReadingSidePadding.current
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LiveNowBar(
|
||||
cache = localCache,
|
||||
relayManager = relayManager,
|
||||
scopeAuthors = if (feedMode == FeedMode.FOLLOWING) followedUsers else null,
|
||||
onOpenLive = onOpenLive,
|
||||
modifier = Modifier.padding(horizontal = liveBarPadding + 12.dp),
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// Feed content based on FeedState
|
||||
when (val state = feedState) {
|
||||
is FeedState.Loading -> {
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* 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.desktop.ui.live
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivitySorting
|
||||
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivitySorting.LiveActivityRank
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Ranks + filters cached NIP-53 live-stream channels for the Desktop Discover grid and the
|
||||
* per-column live bar, delegating the ordering contract to the shared, unit-tested
|
||||
* [LiveActivitySorting] (so Desktop and Android order streams identically).
|
||||
*
|
||||
* [isOfflineNow] lets the caller feed in the online-probe verdict for a stream's `.m3u8` so a dead
|
||||
* `status=live` sinks; pass `{ false }` until the probe is wired in.
|
||||
*/
|
||||
object LiveActivityRanking {
|
||||
fun rankOf(
|
||||
channel: LiveActivitiesChannel,
|
||||
followSet: Set<String>,
|
||||
isOfflineNow: Boolean,
|
||||
): LiveActivityRank {
|
||||
val info = channel.info
|
||||
if (info == null) {
|
||||
return LiveActivityRank(LiveActivitySorting.ORDER_ENDED, 0, 0, 0L, channel.address.toValue())
|
||||
}
|
||||
val participants = info.participants()
|
||||
val involved =
|
||||
buildSet {
|
||||
add(info.pubKey)
|
||||
participants.forEach { add(it.pubKey) }
|
||||
}
|
||||
val followParticipants = involved.count { it in followSet }
|
||||
val startOrCreated = info.starts() ?: info.createdAt
|
||||
return LiveActivityRank(
|
||||
statusOrder = LiveActivitySorting.statusOrder(info.status(), isOfflineNow),
|
||||
followParticipants = followParticipants,
|
||||
totalParticipants = participants.size,
|
||||
startOrCreated = startOrCreated,
|
||||
idHex = channel.address.toValue(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Best-first ranked list for the Discover grid (live > planned > ended). */
|
||||
fun rankForDiscover(
|
||||
channels: Collection<LiveActivitiesChannel>,
|
||||
followSet: Set<String>,
|
||||
isOfflineNow: (LiveActivitiesChannel) -> Boolean = { false },
|
||||
): List<LiveActivitiesChannel> = LiveActivitySorting.sortDescending(channels) { rankOf(it, followSet, isOfflineNow(it)) }
|
||||
|
||||
/**
|
||||
* The set of channels currently live-and-fresh within [followSet] (or globally when [followSet]
|
||||
* is null), for the per-column "live now" bar. Ranked by viewer count (`current_participants`)
|
||||
* per the design decision, newest first as a tiebreak.
|
||||
*/
|
||||
fun liveNowForBar(
|
||||
channels: Collection<LiveActivitiesChannel>,
|
||||
followSet: Set<String>?,
|
||||
now: Long = TimeUtils.now(),
|
||||
isOfflineNow: (LiveActivitiesChannel) -> Boolean = { false },
|
||||
): List<LiveActivitiesChannel> {
|
||||
val eligible =
|
||||
channels.filter { channel ->
|
||||
val info = channel.info ?: return@filter false
|
||||
val fresh = LiveActivitySorting.isLiveAndFresh(info.status(), info.createdAt, now, isOfflineNow(channel))
|
||||
fresh && (followSet == null || involvesFollow(channel, followSet))
|
||||
}
|
||||
// Rank by viewers (current_participants), then recency. MUST go through sortDescending so the
|
||||
// keys are snapshotted once — channel.info is a var swapped from relay threads, and reading it
|
||||
// lazily inside a comparator violates TimSort's contract and crashes the feed column.
|
||||
return LiveActivitySorting.sortDescending(eligible) { channel ->
|
||||
val info = channel.info
|
||||
LiveActivityRank(
|
||||
statusOrder = LiveActivitySorting.ORDER_LIVE,
|
||||
followParticipants = 0,
|
||||
totalParticipants = info?.currentParticipants() ?: 0,
|
||||
startOrCreated = info?.createdAt ?: 0L,
|
||||
idHex = channel.address.toValue(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun involvesFollow(
|
||||
channel: LiveActivitiesChannel,
|
||||
followSet: Set<String>,
|
||||
): Boolean {
|
||||
val info = channel.info ?: return false
|
||||
if (info.pubKey in followSet) return true
|
||||
return info.participants().any { it.pubKey in followSet }
|
||||
}
|
||||
|
||||
/** Client-side search over cached streams: title, host name, and hashtags. */
|
||||
fun matchesQuery(
|
||||
channel: LiveActivitiesChannel,
|
||||
query: String,
|
||||
): Boolean {
|
||||
if (query.isBlank()) return true
|
||||
val q = query.trim()
|
||||
val info = channel.info ?: return false
|
||||
if (info.title()?.contains(q, ignoreCase = true) == true) return true
|
||||
if (channel.creatorName()?.contains(q, ignoreCase = true) == true) return true
|
||||
if (info.summary()?.contains(q, ignoreCase = true) == true) return true
|
||||
return info.tags.any { tag -> HashtagTag.parse(tag)?.contains(q, ignoreCase = true) == true }
|
||||
}
|
||||
|
||||
fun isLive(channel: LiveActivitiesChannel): Boolean = channel.info?.status() == StatusTag.STATUS.LIVE
|
||||
|
||||
fun isPlanned(channel: LiveActivitiesChannel): Boolean = channel.info?.status() == StatusTag.STATUS.PLANNED
|
||||
|
||||
/**
|
||||
* True only when the stream is genuinely live right now: `status=live` AND its 30311 is fresh
|
||||
* (re-published within the freshness window). Used to keep the "LIVE NOW" surfaces free of
|
||||
* ended/planned/zombie streams.
|
||||
*/
|
||||
fun isLiveNow(
|
||||
channel: LiveActivitiesChannel,
|
||||
now: Long = TimeUtils.now(),
|
||||
isOfflineNow: Boolean = false,
|
||||
): Boolean {
|
||||
val info = channel.info ?: return false
|
||||
return LiveActivitySorting.isLiveAndFresh(info.status(), info.createdAt, now, isOfflineNow)
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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.desktop.ui.live
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.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.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createLiveActivitiesSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
|
||||
/**
|
||||
* Compact pinned "live now" bar for the top of a feed column. Shows the single most-watched live
|
||||
* host in the column's audience ([scopeAuthors] = the column's follows, or null for the global
|
||||
* feed) plus a "+N live ›" affordance that expands the rest. Hidden entirely when nobody in scope
|
||||
* is live. Clicking a host opens the watch screen via [onOpenLive].
|
||||
*/
|
||||
@Composable
|
||||
fun LiveNowBar(
|
||||
cache: DesktopLocalCache,
|
||||
relayManager: RelayConnectionManager,
|
||||
scopeAuthors: Set<String>?,
|
||||
onOpenLive: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
|
||||
// Keyed on the Set (stable identity from the account state holder) — not a fresh list per
|
||||
// recomposition — so the subscription and the snapshot below don't churn on unrelated recomposes.
|
||||
rememberSubscription(connectedRelays, scopeAuthors, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
createLiveActivitiesSubscription(
|
||||
relays = connectedRelays,
|
||||
authors = scopeAuthors?.toList(),
|
||||
onEvent = { event, _, relay, _ -> cache.consume(event, relay) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val version by cache.liveActivityVersion.collectAsState()
|
||||
val liveNow =
|
||||
remember(version, scopeAuthors) {
|
||||
LiveActivityRanking.liveNowForBar(cache.snapshotLiveActivities(), scopeAuthors)
|
||||
}
|
||||
|
||||
if (liveNow.isEmpty()) return
|
||||
|
||||
val top = liveNow.first()
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable { onOpenLive(top.address.toValue()) }
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
LiveDotSmall()
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = topLabel(top.toBestDisplayName()),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (liveNow.size > 1) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Box {
|
||||
Text(
|
||||
text = "+${liveNow.size - 1} live ›",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.clickable { expanded = true },
|
||||
)
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
liveNow.forEach { channel ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(channel.toBestDisplayName(), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onOpenLive(channel.address.toValue())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun topLabel(name: String): String = "$name is live"
|
||||
|
||||
@Composable
|
||||
private fun LiveDotSmall() {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(9.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFFD32F2F)),
|
||||
)
|
||||
}
|
||||
+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.desktop.ui.live
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* App-level controller for the full-window live watch screen. Holds the address (`kind:pubkey:d`)
|
||||
* of the stream currently being watched, or null when the watch overlay is closed.
|
||||
*
|
||||
* This is a singleton (like [com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer])
|
||||
* so any live surface — the Discover grid card, the per-column live bar, a profile Streams tab —
|
||||
* can open the watch overlay via [open] without threading a callback up through the whole deck /
|
||||
* single-pane navigation tree. The overlay itself is rendered once at the composition root in
|
||||
* `Main.kt`, observing [current].
|
||||
*/
|
||||
object LiveWatchController {
|
||||
private val _current = MutableStateFlow<String?>(null)
|
||||
val current: StateFlow<String?> = _current.asStateFlow()
|
||||
|
||||
fun open(streamAddress: String) {
|
||||
_current.value = streamAddress
|
||||
}
|
||||
|
||||
fun close() {
|
||||
_current.value = null
|
||||
}
|
||||
}
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
/*
|
||||
* 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.desktop.ui.live
|
||||
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.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.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createLiveChatSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
|
||||
|
||||
/**
|
||||
* Full-window watch screen for a NIP-53 live stream: HLS player on the left, live chat (kind 1311)
|
||||
* on the right, host/metadata + status below the player. Rendered as an app-root overlay driven by
|
||||
* [LiveWatchController]; [onClose] tears it down and returns to the deck / single-pane view.
|
||||
*
|
||||
* v1 scope: watch + read + post chat. Zap-the-stream and the live-vs-VOD player refinements
|
||||
* (seek-bar suppression, stall watchdog) are follow-ups.
|
||||
*/
|
||||
@Composable
|
||||
fun LiveWatchScreen(
|
||||
address: String,
|
||||
cache: DesktopLocalCache,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
account: AccountState.LoggedIn?,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val channel =
|
||||
remember(address) {
|
||||
Address.parse(address)?.let { cache.getOrCreateLiveActivityChannel(it) }
|
||||
}
|
||||
|
||||
LaunchedEffect(address, channel?.info) {
|
||||
val info = channel?.info
|
||||
println(
|
||||
"LiveWatchScreen open address=$address status=${info?.status()} " +
|
||||
"streaming=${info?.streaming() ?: "<none>"} recording=${info?.recording() ?: "<none>"}",
|
||||
)
|
||||
}
|
||||
|
||||
// Stop playback when the watch overlay closes (leaves composition) so audio/decoding doesn't
|
||||
// keep running in the background. Switching to another stream keeps the overlay composed (only
|
||||
// `address` changes), so this fires only on a real close.
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { GlobalMediaPlayer.stopVideo() }
|
||||
}
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) {
|
||||
if (channel == null) {
|
||||
WatchError("This stream could not be opened.", onClose)
|
||||
return@Surface
|
||||
}
|
||||
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
|
||||
// Live chat subscription for this stream (kind 1311 by the root `a` tag), torn down on close.
|
||||
rememberSubscription(connectedRelays, address, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
createLiveChatSubscription(
|
||||
relays = connectedRelays,
|
||||
streamAddress = address,
|
||||
onEvent = { event, _, relay, _ -> cache.consume(event, relay) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Top bar
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 16.dp, bottom = 8.dp),
|
||||
) {
|
||||
IconButton(onClick = onClose) {
|
||||
SymbolIcon(symbol = MaterialSymbols.Close, contentDescription = "Close")
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// Left: player + metadata
|
||||
Column(modifier = Modifier.weight(1f).fillMaxSize().padding(16.dp)) {
|
||||
val streamUrl = channel.info?.streaming()
|
||||
if (streamUrl != null) {
|
||||
DesktopVideoPlayer(
|
||||
url = streamUrl,
|
||||
autoPlay = true,
|
||||
isLive = channel.info?.status() == StatusTag.STATUS.LIVE,
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(RoundedCornerShape(8.dp)),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("Stream is offline", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
WatchHeader(channel = channel)
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// Right: live chat
|
||||
Column(modifier = Modifier.width(380.dp).fillMaxSize()) {
|
||||
Text(
|
||||
text = "LIVE CHAT",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
HorizontalDivider()
|
||||
ChatColumn(
|
||||
channel = channel,
|
||||
account = account,
|
||||
relayManager = relayManager,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchHeader(channel: com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel) {
|
||||
val info = channel.info
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
if (info?.status() == StatusTag.STATUS.LIVE) {
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFFD32F2F)))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("LIVE", style = MaterialTheme.typography.labelMedium, color = Color(0xFFD32F2F), fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
val host = channel.creatorName()
|
||||
if (host != null) {
|
||||
Text(
|
||||
text = host,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
val viewers = info?.currentParticipants()
|
||||
if (viewers != null) {
|
||||
Text("$viewers watching", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
val summary = channel.summary()
|
||||
if (!summary.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatColumn(
|
||||
channel: com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel,
|
||||
account: AccountState.LoggedIn?,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val notesState by channel
|
||||
.flow()
|
||||
.notes.stateFlow
|
||||
.collectAsState()
|
||||
// Newest first so reverseLayout puts the latest message at the visual bottom.
|
||||
val messages =
|
||||
remember(notesState) {
|
||||
channel.notes
|
||||
.values()
|
||||
.filter { it.event is LiveActivitiesChatMessageEvent }
|
||||
.sortedByDescending { it.createdAt() ?: 0L }
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
// Auto-stick to the newest message while the user is at the bottom (index 0 in reverseLayout).
|
||||
// Keyed on the newest message id (not size) so it still fires once the 500-cap prune keeps size
|
||||
// flat (drop-one/add-one) on a busy stream.
|
||||
LaunchedEffect(messages.firstOrNull()?.idHex) {
|
||||
if (messages.isNotEmpty() && listState.firstVisibleItemIndex <= 1) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
items(messages, key = { it.idHex }) { note ->
|
||||
ChatMessageRow(
|
||||
author = note.author?.toBestDisplayName() ?: "anon",
|
||||
content = note.event?.content.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
ChatComposer(channel = channel, account = account, relayManager = relayManager)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatMessageRow(
|
||||
author: String,
|
||||
content: String,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = author,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(text = content, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatComposer(
|
||||
channel: com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel,
|
||||
account: AccountState.LoggedIn?,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var text by remember { mutableStateOf("") }
|
||||
var sending by remember { mutableStateOf(false) }
|
||||
|
||||
val canPost = account != null && !account.isReadOnly
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
enabled = canPost && !sending,
|
||||
singleLine = true,
|
||||
placeholder = { Text(if (canPost) "Say something…" else "Log in to chat") },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(
|
||||
enabled = canPost && !sending && text.isNotBlank(),
|
||||
onClick = {
|
||||
val acct = account ?: return@TextButton
|
||||
val body = text.trim()
|
||||
if (body.isBlank()) return@TextButton
|
||||
sending = true
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val template = LiveActivitiesChatMessageEvent.message(body, channel.toATag())
|
||||
val signed = acct.signer.sign(template)
|
||||
relayManager.publish(signed, relayManager.connectedRelays.value)
|
||||
}
|
||||
text = ""
|
||||
} finally {
|
||||
sending = false
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Send")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchError(
|
||||
message: String,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Text(message, style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TextButton(onClick = onClose) { Text("Close") }
|
||||
}
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* 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.desktop.ui.live
|
||||
|
||||
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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.FlowRowOverflow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createLiveActivitiesSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* "LIVE NOW" section for the Desktop Discover screen: subscribes to NIP-53 live streams (kind
|
||||
* 30311) while visible, ranks them via the shared [LiveActivityRanking] (live > planned > ended,
|
||||
* follow-participation, then viewers), and offers a client-side search over title / host / hashtag.
|
||||
* Clicking a card invokes [onOpenLive] with the stream's address (`kind:pubkey:d`).
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun LivesSection(
|
||||
cache: DesktopLocalCache,
|
||||
relayManager: RelayConnectionManager,
|
||||
followSet: Set<String>,
|
||||
onOpenLive: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
|
||||
// Subscribe to global live streams while this section is composed; torn down on dispose.
|
||||
rememberSubscription(connectedRelays, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
createLiveActivitiesSubscription(
|
||||
relays = connectedRelays,
|
||||
authors = null,
|
||||
onEvent = { event, _, relay, _ -> cache.consume(event, relay) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val version by cache.liveActivityVersion.collectAsState()
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
// "LIVE NOW" shows only streams that are genuinely live right now — never planned or ended.
|
||||
val ranked =
|
||||
remember(version, query, followSet) {
|
||||
val liveOnly = cache.snapshotLiveActivities().filter { LiveActivityRanking.isLiveNow(it) }
|
||||
LiveActivityRanking
|
||||
.rankForDiscover(liveOnly, followSet)
|
||||
.filter { LiveActivityRanking.matchesQuery(it, query) }
|
||||
}
|
||||
|
||||
// Hide the whole section when nothing is live (keeps Discover clean; no empty band).
|
||||
if (ranked.isEmpty() && query.isBlank()) return
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
LiveDot()
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "LIVE NOW",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
singleLine = true,
|
||||
placeholder = { Text("Search live streams…") },
|
||||
keyboardOptions = KeyboardOptions.Default,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (ranked.isEmpty()) {
|
||||
Text(
|
||||
text = "No live streams match \"$query\".",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
// Cap at two rows so the rest of Discover ("From the pack" etc.) stays visible; extra
|
||||
// live streams are still reachable via the search box and the per-column live bar.
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
maxLines = 2,
|
||||
overflow = FlowRowOverflow.Clip,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
ranked.forEach { channel ->
|
||||
LiveStreamCard(
|
||||
channel = channel,
|
||||
onClick = { onOpenLive(channel.address.toValue()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LiveStreamCard(
|
||||
channel: LiveActivitiesChannel,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val info = channel.info
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(240.dp)
|
||||
.clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Box {
|
||||
val image = channel.profilePicture()
|
||||
if (image != null) {
|
||||
AsyncImage(
|
||||
model = image,
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
}
|
||||
StatusBadge(
|
||||
channel = channel,
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(6.dp),
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val host = channel.creatorName()
|
||||
if (host != null) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = host,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
val viewers = info?.currentParticipants()
|
||||
if (viewers != null && LiveActivityRanking.isLive(channel)) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "$viewers watching",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusBadge(
|
||||
channel: LiveActivitiesChannel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val status = channel.info?.status()
|
||||
when (status) {
|
||||
StatusTag.STATUS.LIVE ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color(0xE6D32F2F))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text("LIVE", style = MaterialTheme.typography.labelSmall, color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
StatusTag.STATUS.PLANNED ->
|
||||
Text(
|
||||
text = startsInLabel(channel.info?.starts()),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
modifier =
|
||||
modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color(0xCC000000))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LiveDot() {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFFD32F2F)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun startsInLabel(startsAt: Long?): String {
|
||||
if (startsAt == null) return "SCHEDULED"
|
||||
val delta = startsAt - TimeUtils.now()
|
||||
if (delta <= 0) return "SCHEDULED"
|
||||
val hours = delta / 3600
|
||||
val minutes = (delta % 3600) / 60
|
||||
return when {
|
||||
hours >= 24 -> "in ${hours / 24}d"
|
||||
hours >= 1 -> "in ${hours}h"
|
||||
else -> "in ${minutes}m"
|
||||
}
|
||||
}
|
||||
+10
@@ -63,6 +63,7 @@ fun DesktopVideoPlayer(
|
||||
viewMode: ViewMode = ViewMode.DEFAULT,
|
||||
onViewModeChange: ((ViewMode) -> Unit)? = null,
|
||||
trailingControls: @Composable (() -> Unit)? = null,
|
||||
isLive: Boolean = false,
|
||||
) {
|
||||
val videoState by GlobalMediaPlayer.videoState.collectAsState()
|
||||
val isActiveVideo = videoState.url == url
|
||||
@@ -89,6 +90,14 @@ fun DesktopVideoPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
// Surface playback errors to the log — kdroidFilter reports these only via state, so without
|
||||
// this a stream that fails to start ("live doesn't start") leaves no trace.
|
||||
LaunchedEffect(isActiveVideo, videoState.errorReason) {
|
||||
if (isActiveVideo && videoState.errorReason != null) {
|
||||
println("DesktopVideoPlayer ERROR url=$url reason=${videoState.errorReason}")
|
||||
}
|
||||
}
|
||||
|
||||
if (isActiveVideo && videoState.aspectRatio != 16f / 9f) {
|
||||
aspectRatio = videoState.aspectRatio
|
||||
}
|
||||
@@ -167,6 +176,7 @@ fun DesktopVideoPlayer(
|
||||
},
|
||||
onViewModeChange = onViewModeChange,
|
||||
trailingControls = trailingControls,
|
||||
isLive = isLive,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+43
-17
@@ -34,6 +34,7 @@ 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.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -72,6 +73,7 @@ fun VideoControls(
|
||||
viewMode: ViewMode = ViewMode.DEFAULT,
|
||||
onViewModeChange: ((ViewMode) -> Unit)? = null,
|
||||
trailingControls: @Composable (() -> Unit)? = null,
|
||||
isLive: Boolean = false,
|
||||
) {
|
||||
var hovering by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -128,18 +130,21 @@ fun VideoControls(
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.padding(horizontal = 8.dp),
|
||||
) {
|
||||
// Seek slider (full width, no horizontal competition)
|
||||
Slider(
|
||||
value = position,
|
||||
onValueChange = onSeek,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
SliderDefaults.colors(
|
||||
thumbColor = Color.White,
|
||||
activeTrackColor = MaterialTheme.colorScheme.primary,
|
||||
inactiveTrackColor = Color.White.copy(alpha = 0.3f),
|
||||
),
|
||||
)
|
||||
// Seek slider (full width, no horizontal competition). Hidden for live streams —
|
||||
// the underlying HLS is non-seekable, so a scrubber would be inert and misleading.
|
||||
if (!isLive) {
|
||||
Slider(
|
||||
value = position,
|
||||
onValueChange = onSeek,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
SliderDefaults.colors(
|
||||
thumbColor = Color.White,
|
||||
activeTrackColor = MaterialTheme.colorScheme.primary,
|
||||
inactiveTrackColor = Color.White.copy(alpha = 0.3f),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Buttons row
|
||||
Row(
|
||||
@@ -159,11 +164,32 @@ fun VideoControls(
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${formatTime(currentTime)} / ${formatTime(duration)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
)
|
||||
if (isLive) {
|
||||
// Live: a single "elapsed since you started watching" timer + a LIVE dot —
|
||||
// no total duration (the stream has no fixed end).
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.background(Color(0xFFD32F2F), shape = CircleShape),
|
||||
)
|
||||
Text(
|
||||
text = "LIVE",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color(0xFFFF5252),
|
||||
)
|
||||
Text(
|
||||
text = formatTime(currentTime),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "${formatTime(currentTime)} / ${formatTime(duration)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
// Spacer pushes right-side controls to the end
|
||||
Box(Modifier.weight(1f))
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
---
|
||||
title: Desktop Live Media (NIP-53) — Consume & Discover
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-08-20
|
||||
origin: docs/brainstorms/2026-08-20-feat-desktop-live-media-brainstorm.md
|
||||
---
|
||||
|
||||
# ✨ Desktop Live Media (NIP-53) — Consume & Discover
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-08-20 (7 parallel research/review agents: performance, architecture,
|
||||
simplicity, flow/edge-cases, security, HLS+chat best-practices, in-repo Compose patterns.)
|
||||
|
||||
### Key improvements folded in
|
||||
1. **Player reality check (kdroidFilter 0.11.4):** no `isLive` flag, no buffered ranges, no
|
||||
stall detector (except Linux). `duration == 0.0` is the live signal **but is ambiguous with
|
||||
loading** → detect live/VOD from the **NIP-53 `status` tag** (+ manifest `#EXT-X-ENDLIST`
|
||||
for precise ended), and **build our own stall/reconnect watchdog**. `seekTo` is a hard no-op
|
||||
at `duration<=0`, so suppressing the seek bar for live is confirmed safe.
|
||||
2. **Chat = one coalescing pipeline**, reusing the existing 250ms `BasicBundledInsert` feed path
|
||||
(do **not** hand-roll a chat list). Ingest→dedup-by-id→moderate→classify→batch→ring-trim(500)
|
||||
→immutable-commit→`LazyColumn(reverseLayout,key=id)`→auto-scroll(`derivedStateOf`+`yield`).
|
||||
3. **Three factual corrections** to the original plan (see ⚠️ callouts): `OnlineChecker` is
|
||||
Android-only (must extract + swap `android.util.LruCache`); `DesktopLocalCache` has **no channel
|
||||
abstraction** (must stand up the first one); the watch-screen host I assumed (window-level
|
||||
overlay) **doesn't exist** — `DesktopScreen`/`navState` are per-column.
|
||||
4. **Security:** URL-scheme allowlist gating both the player **and** the "Open in default player"
|
||||
fallback; probe-on-intent (not on discovery) via the proxy-aware client; verify participant
|
||||
`proof`; sanitize chat (bidi/auto-load).
|
||||
5. **Performance:** the player singleton has a **real unsynchronized race** + 60/s position-tick
|
||||
recompose churn; Desktop has **no pruning** today (must add the trigger); snapshot **all**
|
||||
comparator keys (incl. volatile `current_participants`) to avoid TimSort violations.
|
||||
6. **Scope correction:** the standalone "Lives" sidebar destination is **removed** — it
|
||||
contradicted the brainstorm decision to *fold lives into Discover*.
|
||||
|
||||
### New considerations discovered
|
||||
- Mid-watch lifecycle (live→ended, URL rotation, stall) is the highest-risk cluster and is now
|
||||
first-class in Phase 0/3.
|
||||
- Mute/block enforcement in live chat is a **known silent-no-op gap** on Desktop (memory
|
||||
`desktop_moderation_safety.md`) — must be the explicit enforcement point, not assumed.
|
||||
- Stream-zap recipient semantics (single author vs NIP-53 zap splits) needs a decision.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Bring NIP-53 Live Streaming to Amethyst **Desktop** as a **consumption + discovery** feature:
|
||||
find who/what is live, open a stream, watch the HLS video, read + post live chat, and zap.
|
||||
**Broadcasting is out of scope.**
|
||||
|
||||
Quartz has the full NIP-53 protocol; `commons/` has the `LiveActivitiesChannel` model +
|
||||
top-zappers aggregation; Desktop ships an HLS-capable player (kdroidFilter, MIT); Android has a
|
||||
complete consumer UX to copy. This plan is **wiring + Desktop-native layouts + closing real gaps**
|
||||
(cache/channel routing for 30311/1311; live-vs-VOD player handling + stall recovery; online-probe
|
||||
extraction; a full-window watch host).
|
||||
|
||||
Four surfaces (decided in the brainstorm):
|
||||
1. **Per-column "live now" bar** — pinned above a feed column, scoped to that column's audience;
|
||||
one host (ranked by viewers) + `+N live ›`.
|
||||
2. **Discover integration** — a Lives section + search folded into the existing Discover (no
|
||||
standalone sidebar destination).
|
||||
3. **Full-window watch screen** — HLS player (left) + kind-1311 chat (right) + metadata/zap.
|
||||
4. **Zaps** — zap the stream (30311 address) + chat messages (1311).
|
||||
|
||||
Statuses: **live + planned + ended (recording/VOD)**. Profile gets a **Streams tab**.
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement / Motivation
|
||||
|
||||
Desktop has **zero** NIP-53 UI today while Android is mature and zap.stream has set the "live bar
|
||||
+ watch-with-chat" expectation. Live streaming is high-engagement; Desktop's large screen suits a
|
||||
side-by-side watch+chat layout mobile can't match. Clear parity gap with heavy reuse leverage.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Solution (high level)
|
||||
|
||||
- **Reuse** Quartz protocol + `commons` model/aggregator/player/zap infra.
|
||||
- **Extract pure ranking/status/freshness logic** to `commons/.../nip53LiveActivities/`
|
||||
(shared by Android + Desktop); keep each platform's filter thin over its own cache.
|
||||
- **Close the gaps:** stand up Desktop's first channel cache + route 30311/1311; extract the
|
||||
online-probe to a shared source set; add a live-vs-VOD player mode + stall/reconnect watchdog;
|
||||
add a real full-window watch host.
|
||||
- **Build Desktop-native** surfaces: per-column live bar, Discover Lives section + search,
|
||||
full-window watch screen, profile Streams tab, event-driven planned reminders.
|
||||
|
||||
---
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture & reuse map
|
||||
|
||||
**Reuse as-is — Quartz (`quartz/.../nip53LiveActivities/`):** `streaming/LiveActivitiesEvent.kt`
|
||||
(kind 30311; `checkStatus()` 8h live→ended; `LiveStreamLike`), `chat/LiveActivitiesChatMessageEvent.kt`
|
||||
(kind 1311; builders `.message(content, hint)` / `.reply(content, replyingTo)`), tag parsers under
|
||||
`streaming/tags/`.
|
||||
|
||||
**Reuse as-is — commons:** `model/nip53LiveActivities/LiveActivitiesChannel.kt` (CLI-safe: address,
|
||||
creator, `info`, `presenceNotes`, `pruneStalePresence`), `nip53LiveActivities/LiveActivityTopZappersAggregator.kt`
|
||||
+ `viewmodels/LiveStreamTopZappersViewModel.kt`, `nip53LiveActivities/ui/StreamSystemCard.kt`,
|
||||
feed DAL (`ui/feeds/`), `viewmodels/FeedViewModel.kt` + `ListChangeFeedViewModel`.
|
||||
|
||||
**Extract Android → commons (pure logic only):** `convertStatusToOrder` + the
|
||||
`compareBy(status, participantCount, allParticipants, startTime, idHex).reversed()` comparator from
|
||||
`amethyst/.../discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt:116-134` → new
|
||||
`commons/.../nip53LiveActivities/LiveActivitySorting.kt`. Freshness cutoff + viewer/follow ranking
|
||||
from `HomeLiveFilter.kt`. **Keep the `MeetingRoom`/`MeetingSpace` branches intact** (Android
|
||||
audio-rooms callers depend on the same helper) or provide a `LiveActivitiesEvent`-only overload.
|
||||
`HomeLiveFilter` (extends amethyst-only `AdditiveComplexFeedFilter<Channel,Note>`) is **not**
|
||||
portable — extract only its helpers.
|
||||
|
||||
> ⚠️ **Correction 1 — `OnlineChecker` is an extraction, not a reuse.** It lives only in
|
||||
> `amethyst/src/main/.../service/OnlineCheck.kt` and hard-depends on **`android.util.LruCache`**
|
||||
> (won't compile in `jvmAndroid`). Task: **move it to `commons/jvmAndroid`, swapping `LruCache`
|
||||
> for a KMP-safe bounded cache** (existing `LargeCache`/`KmpLock` primitives). It already exposes
|
||||
> a `(String) -> OkHttpClient` factory + a **5-min TTL, 100-URL LRU** with same-URL dedup — reuse
|
||||
> those. Confirm Desktop supplies an OkHttp client factory (route it through the **proxy-aware**
|
||||
> `RoleBasedHttpClientBuilder.okHttpClientForVideo` — see Security). OkHttp is banned in
|
||||
> `commonMain` (`verifyKmpPurity`), so `jvmAndroid` is the correct home.
|
||||
|
||||
### Player: live-vs-VOD + stall recovery (kdroidFilter 0.11.4 reality)
|
||||
|
||||
The engine collapses every backend's live signal into `duration == 0.0` and exposes **no** live
|
||||
flag / buffered ranges / (Win+Mac) stall detector. Therefore:
|
||||
|
||||
- **Classify live/VOD from NIP-53, not the player.** Use the 30311 `status` tag as source of truth
|
||||
(`live`/`planned`/`ended`). Use `duration == 0.0` only as a corroborating hint (it's ambiguous
|
||||
with "still loading"). For precise *ended* detection, optionally fetch the `.m3u8` and check
|
||||
`#EXT-X-ENDLIST` (RFC 8216) — resolve a variant first if it's a master playlist.
|
||||
- **Live mode UI:** suppress the seek `Slider` (extend `VideoControls`' existing `viewMode` param;
|
||||
`seekTo` is inert at `duration<=0` so this is safe), show a **"● LIVE" pill** (with hysteresis),
|
||||
no `current/duration` row. "Jump to live" = `stop()` + `openUri(url)` (re-open lands at the edge).
|
||||
Read viewer count from 30311 `current_participants`, not client-side.
|
||||
- **Own stall watchdog (mandatory Win/Mac):** sample `currentTime` ~every 1s; if `isPlaying` but no
|
||||
advance for ~1.25–2s → stalled. OR it with Linux's `isLoading`. Reconnect via `stop()`+`openUri`
|
||||
with bounded exponential backoff + jitter (~1s→cap ~8–30s). **Do not trust** `onPlaybackEnded` /
|
||||
AVFoundation's `DidPlayToEndTime` for "ended" (false-fires on live). Terminal "Stream ended/offline"
|
||||
card only on an authoritative signal (`status=ended`, stale-`live` fallback, or `#EXT-X-ENDLIST`).
|
||||
- **VOD path** (ended streams' `recording`) keeps the existing seekable controls unchanged — the
|
||||
*easy* path.
|
||||
|
||||
> ⚠️ **Player singleton is a real latent bug.** `GlobalMediaPlayer` is an `object`; `playVideo`/
|
||||
> `seek`/`volume`/`toggle` are **not synchronized** (only engine init is). Two callers (NowPlayingBar
|
||||
> feed video + LiveWatch) racing `playVideo(urlA/urlB)` do unguarded RMW on the same engine → torn
|
||||
> state. **Serialize player mutations behind a `Mutex`/`initLock`.** Also: `videoState` updates
|
||||
> ~60×/sec (position) and callers `collectAsState()` the whole struct → 60/s recompose churn.
|
||||
> **Split `position` out** (pass `() -> Float` / a separate `StateFlow<Float>` to the slider); in
|
||||
> **live mode read no position at all** (seek bar suppressed) → zero churn on the common path.
|
||||
> Watch-close `DisposableEffect` must stop playback, cancel the sync job, and reset
|
||||
> `MediaPlaybackState` so stale duration/position doesn't leak into a later VOD/feed video.
|
||||
|
||||
### Data flow: channel cache + routing (the other gap)
|
||||
|
||||
> ⚠️ **Correction 2 — Desktop has no channel abstraction.** `DesktopLocalCache.getAnyChannel()`
|
||||
> returns `null` ("Desktop doesn't support channels yet"). Routing 1311 requires **standing up the
|
||||
> first Desktop channel cache**, not just two `when` cases:
|
||||
> - Add `DesktopLocalCache.liveChatChannels = LargeCache<Address, LiveActivitiesChannel>` +
|
||||
> `getOrCreateLiveChannel(address)` (mirror Android; keep it **live-activity-only**, don't port
|
||||
> the 7-type Android abstraction).
|
||||
> - **30311** → `AddressableNote` in `addressableNotes` with `existing.createdAt >= event.createdAt`
|
||||
> supersession (replaceable ⇒ one entry/stream; bounded, safe). Attach `info` to the channel.
|
||||
> - **1311** → attach to its stream's channel `notes` by root `a` tag.
|
||||
> - **`LocalRelayStore` hydration policy** (Desktop-only write-through SQLite Android lacks):
|
||||
> **hydrate 30311** (replaceable, bounded), **skip 1311** (avoid unbounded chat replay).
|
||||
|
||||
> ⚠️ **Desktop has no pruning today.** `pruneOldMessages()` (caps `channel.notes` to 500 newest,
|
||||
> emits `SetDeletion`) exists on the model but **nothing calls it on Desktop** (no `CachePruner`/
|
||||
> `MemoryTrimmingService`). Add a trigger: prune when the 1311 bundler tick sees `notes.size > 500`;
|
||||
> `pruneStalePresence(now - 20min)` for presence; **evict the channel's `notes` on watch-close** so
|
||||
> watching 5 busy streams doesn't retain 5×∞.
|
||||
|
||||
### Chat: one coalescing pipeline (reuse, don't hand-roll)
|
||||
|
||||
Route 1311 through the **existing** `ListChangeFeedViewModel` → `FeedContentState` path (same as
|
||||
Android's `ChannelFeedViewModel`/`ChannelFeedFilter`). The **250ms `BasicBundledInsert`** already
|
||||
coalesces hundreds of arrivals into ~4 list emissions/sec; stable `key = { it.idHex }`; list capped
|
||||
at `limit()=500`. The pipeline order: **ingest → dedup by event id (multi-relay dups) → moderate
|
||||
(mute/block + spam) → classify (message vs zap tier) → batch (250ms; bump to 300–500ms if jank) →
|
||||
ring-trim (500, only while at bottom) → commit one immutable list → `LazyColumn(reverseLayout=true,
|
||||
key=id, @Immutable rows)` → auto-scroll**.
|
||||
- Auto-scroll: stick to bottom only while at/near bottom (`derivedStateOf` on
|
||||
`firstVisibleItemIndex==0 && offset<threshold`); floating "↓ N new" pill when scrolled up;
|
||||
**`yield()` before `animateScrollToItem(0)`** (layout-race fix). Desktop: **hover-to-pause**
|
||||
auto-scroll (mouse-first).
|
||||
- Reuse the existing desktop `ChatPane.kt` (already `reverseLayout` + auto-scroll) as the template.
|
||||
- **Note:** `DesktopCacheEventStream` is `DROP_OLDEST` (buffer 64) → chat may gap under sustained
|
||||
burst (acceptable for chat) **but the 30311 metadata refresh must not share that lossy stream**
|
||||
(never drop a status→live/ended transition).
|
||||
|
||||
### Pinned chat + inline zaps
|
||||
- **Pinned** messages come from 30311 `["pinned", "<1311 id>"]` tags (host-controlled, we're already
|
||||
subscribed to 30311) → resolve to the 1311 and render a sticky banner above the list. *(Nice-to-have;
|
||||
keep if cheap.)*
|
||||
- **Zaps inline**: model rows as a sealed `ChatRow.Message | ChatRow.Zap(amountSats, zapper, comment)`;
|
||||
amount from the 9735 receipt's bolt11; amount-based visual tier. Same id-dedup as messages.
|
||||
|
||||
### Watch-screen host
|
||||
|
||||
> ⚠️ **Correction 3 — there is no window-level overlay host.** `DesktopScreen`/`navState`/
|
||||
> `OverlayContent` are **per-column** (`navState = remember(column.id)`), so in **deck mode** a push
|
||||
> lands in a ~400px column — exactly the side-by-side-impossible layout the brainstorm rejected.
|
||||
> Only **single-pane** mode's overlay `fillMaxSize()`. **Decision needed (blocks Phase 3):**
|
||||
> - **(a) ✅ CHOSEN** — Add a genuine window-level overlay host at the `App()`/composition root in
|
||||
> `Main.kt` (above both `DeckColumnContainer` and `SinglePaneLayout`), app-scoped nav state. The
|
||||
> watch screen spans the whole window (player left / chat right). This is the first true full-window
|
||||
> route — new app-scoped nav state, **not** the per-column `ColumnNavigationState`.
|
||||
> - (b) Separate top-level Compose `Window` — not chosen.
|
||||
> - (c) Single-pane overlay + max-width column — not chosen.
|
||||
|
||||
### State-holder placement
|
||||
Per `commons/ARCHITECTURE.md`: the watch/chat **state holder** (chat list assembly, status,
|
||||
zapper wiring) goes in **`commons/viewmodels`** (reuse `LiveStreamTopZappersViewModel`); only the
|
||||
Compose screen + subscription wiring live in `desktopApp`. `LiveActivitySorting.kt` (CLI-safe pure
|
||||
logic) sits beside `LiveActivityTopZappersAggregator.kt` in `commons/.../nip53LiveActivities/`.
|
||||
|
||||
### Sort stability
|
||||
`LiveActivitySorting` must take a **pre-snapshotted** `Map<Note,Int>` of comparator keys (status
|
||||
**and** the volatile `current_participants` — the desktop bar sorts by it, more exposed than
|
||||
Android's home bar) — never a live accessor inside the comparator (else `"Comparison method
|
||||
violates its general contract"`). Unit-test with concurrent mutation during sort.
|
||||
|
||||
### Integration points (verified file:line)
|
||||
|
||||
| Surface | File:location | Change |
|
||||
|---|---|---|
|
||||
| Column type | `desktopApp/.../ui/deck/DeckColumnType.kt:25-145` | *(no new Lives destination — folded into Discover)* |
|
||||
| Per-column live bar | `desktopApp/.../ui/FeedScreen.kt` (above feed `LazyColumn`; `feedMode` @~649) | insert `LiveNowBar(scope=feedMode)` pinned header (Following/Global; see scope note) |
|
||||
| Discover Lives + search | `desktopApp/.../followpacks/ui/DiscoverScreen.kt:79-265` | add Lives section + client-side search box |
|
||||
| Profile Streams tab | `desktopApp/.../ui/UserProfileScreen.kt` (tabs ~1191, `when` ~1249) | add "Streams" tab #11 |
|
||||
| Watch host | `Main.kt` App root / new window | per Correction 3 |
|
||||
| Cache routing | `desktopApp/.../cache/DesktopLocalCache.kt` `route()` | 30311/1311 + `liveChatChannels` + prune trigger |
|
||||
| Player | `desktopApp/.../ui/media/{DesktopVideoPlayer,VideoControls}.kt`, `service/media/GlobalMediaPlayer.kt` | live mode + watchdog + `Mutex` + position split |
|
||||
| Zap | `desktopApp/.../ui/NoteActions.kt` (`zapNote`, `ZapAmountDialog`) | target 30311 addr + 1311 |
|
||||
| Subscriptions | `desktopApp/.../subscriptions/SubscriptionUtils.kt` (`rememberSubscription`) | `{kinds:[30311]}` discovery, `{kinds:[1311],#a}` watch |
|
||||
| Online probe | extract → `commons/jvmAndroid` | per Correction 1 |
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Phase 0 — De-risking spikes (do first; some are now blocking)
|
||||
- **Live HLS spike:** point `DesktopVideoPlayer` at a real live zap.stream `.m3u8`; confirm
|
||||
`duration==0.0` on live, seek no-op, error surface on a dead stream; prototype the stall watchdog
|
||||
+ `stop()`+`openUri` reconnect. Validate on macOS at minimum; note Win/Mac lack a native stall flag.
|
||||
- **Watch host decision** (Correction 3): choose (a)/(b)/(c) — **blocks Phase 3**.
|
||||
- **`OnlineChecker` extraction** (Correction 1): move to `commons/jvmAndroid`, swap `LruCache`,
|
||||
confirm Desktop OkHttp factory + proxy-aware routing.
|
||||
- **Deliverable:** spike note in `desktopApp/plans/`.
|
||||
|
||||
#### Phase 1 — Data layer (commons + desktop cache)
|
||||
- `commons/.../nip53LiveActivities/LiveActivitySorting.kt` — pure status-order + freshness +
|
||||
viewer/follow ranking, **snapshot-map API** (+ unit tests incl. overdue-planned, zombie-live→ended,
|
||||
latest-per-address dedupe, host-I-follow vs participant, concurrent-mutation). Refactor Android
|
||||
filters to call it (hard AC — prevents drift).
|
||||
- `DesktopLocalCache`: `liveChatChannels` + `getOrCreateLiveChannel`; route 30311 (supersession) +
|
||||
1311 (attach); prune trigger (cap 500 + `pruneStalePresence(20min)`); `LocalRelayStore` hydration
|
||||
(30311 yes, 1311 no).
|
||||
- Desktop filters: `DesktopLiveActivityDiscoverFilter` (status LIVE>PLANNED>ENDED + online-downgrade +
|
||||
viewer sort) and `DesktopLiveNowFilter` (follows + 15-min fresh + rank by `current_participants`).
|
||||
|
||||
#### Phase 2 — Discovery + per-column live bar
|
||||
- `LiveNowBar` composable: single host (ranked by `current_participants`, snapshotted) + `+N live ›`;
|
||||
scoped by column `feedMode`; **online-gated (one probe loop, visible-only, reuse 5-min LRU)**;
|
||||
immutable `StateFlow` + `distinctUntilChanged` (no recompose when ranking unchanged); pinned above
|
||||
the feed `LazyColumn`. **Scope: Following + Global columns** (see note); generalize later.
|
||||
- Discover Lives section + client-side search (title/host/hashtag over cached 30311s) — grid of
|
||||
live + planned + VOD cards with `LiveFlag`/`ScheduledFlag`/"starts in…" badges. No standalone
|
||||
sidebar destination.
|
||||
|
||||
#### Phase 3 — Watch screen (player + chat + zap)
|
||||
- `LiveWatchScreen(address)` in the chosen host: player (live mode, watchdog, no seek bar) left;
|
||||
chat pipeline right (read/post via `.message`/`.reply`); header (title, host, participant roles,
|
||||
viewer count, status badge, live metadata bound to StateFlow). Online-probe before mount → offline/
|
||||
ended placeholder (play `recording` VOD if present). Mid-watch transitions: live→ended banner
|
||||
(disable composer, offer recording; never auto-close); URL rotation → re-point player.
|
||||
- Zap **the stream** (30311 addr); reuse `ZapAmountDialog`. **Decide zap recipient semantics**
|
||||
(single author vs NIP-53 zap splits). Disable-with-reason when no LN address / NWC not connected.
|
||||
Chat mute/block filtering is the enforcement point (memory `desktop_moderation_safety.md`).
|
||||
**Per-message chat zap + top-zappers header are deferred to v1.5** (chat still renders inline zap
|
||||
rows received from relays — just no zap-a-message action / leaderboard).
|
||||
- 2nd-stream-open + close teardown: cancel both subscriptions + reset player before mounting next.
|
||||
|
||||
#### Phase 4 — Profile Streams tab + planned badge
|
||||
- `UserProfileScreen` "Streams" tab: user's 30311s (live + past; VOD via `recording`). **Kept in v1.**
|
||||
- Planned streams: **"starts in…" badge only** (nearly free; reuse `ScheduledFlag`). Overdue-planned
|
||||
relabeled/downranked. **"Remind me" notify machinery is deferred** (no OS scheduler; event-driven
|
||||
in-app-only had low hit-rate — revisit when an OS-notification path lands).
|
||||
|
||||
#### Phase 5 — Polish, tests, format
|
||||
- Reuse `MaterialSymbols.Videocam` (no new codepoint → **no font subset regen**); LIVE badge = red
|
||||
dot + text.
|
||||
- Unit tests (see Phase 1). `./gradlew spotlessApply`; compile `commons` (JVM+iOS purity),
|
||||
`desktopApp`, `amethyst`, `cli`.
|
||||
- Manual testing sheet `desktopApp/plans/2026-08-20-live-media-manual-testing.md` (Linux GStreamer note).
|
||||
|
||||
### Optional v1 trim (simplicity review — user decision)
|
||||
|
||||
The simplicity reviewer recommends a smaller v1 (some items conflict with brainstorm choices — flagged
|
||||
for you, **not** auto-applied): live bar on **Following/Global only** (already adopted above; the
|
||||
per-`feedMode` scoping for hashtag/list/search columns is the riskiest part); **defer** the Profile
|
||||
Streams tab; **cut** planned "Remind me" (fires only when app is open + stream happens to flip live —
|
||||
high plumbing-to-payoff, gated on absent OS scheduler; keep the passive badge); **defer** per-message
|
||||
chat zap + top-zappers header + host-pinned. Core loop (find → watch → chat → zap-stream + VOD)
|
||||
survives every cut. See Open Questions.
|
||||
|
||||
---
|
||||
|
||||
## Alternative Approaches Considered
|
||||
- **Watch as a deck column** — rejected (400px can't hold side-by-side video+chat).
|
||||
- **Standalone "Lives" sidebar destination** — removed; brainstorm chose *fold into Discover*.
|
||||
- **Single global sidebar "Live Now"** — rejected for per-column contextual bar.
|
||||
- **New player dep (VLCJ/ffmpeg)** — rejected: VLCJ is GPL (MIT-licensing rule); kdroidFilter (MIT)
|
||||
already does HLS.
|
||||
- **NIP-50 relay search for lives** — deferred; v1 search is client-side over cached 30311s.
|
||||
- **Hand-rolled chat throttling** — unnecessary; the 250ms `BasicBundledInsert` already exists.
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
### Interaction graph
|
||||
Discover/bar → subscribe `{kinds:[30311]}` → `DesktopLocalCache.consume` builds `LiveActivitiesChannel`
|
||||
→ filters scan cache → cards render. Open card → watch host → `GlobalMediaPlayer.playVideo(streaming())`
|
||||
(serialized) + `rememberSubscription(1311,#a)` → chat pipeline → `zapNote()`.
|
||||
|
||||
### Error & failure propagation
|
||||
- Dead `status=live` → probe false → offline placeholder (never a spinner). Player stall → watchdog
|
||||
→ "Reconnecting…" → Retry. Player error → scheme-checked "Open in default player" fallback.
|
||||
- Zap failure → existing `ZapFeedback.Error/Timeout` snackbar. No relays → `rememberSubscription`
|
||||
returns null (no crash).
|
||||
|
||||
### State lifecycle risks
|
||||
- **Cache growth (1311 unbounded):** Desktop has no prune — add the trigger (cap 500) + evict on
|
||||
watch-close. 30311 bounded (replaceable). Presence pruned at 20-min cutoff.
|
||||
- **Player singleton:** race (fix with `Mutex`) + 60/s position churn (split position out; live mode
|
||||
reads none) + source-switch must reset `MediaPlaybackState`.
|
||||
- **Reminder persistence:** expire dormant reminders.
|
||||
- **Lossy event stream** (`DROP_OLDEST`): fine for chat; 30311 metadata must not share it.
|
||||
|
||||
### API surface parity
|
||||
- Android + Desktop share `LiveActivitySorting` (hard AC → no ordering drift). `amy` CLI out of scope
|
||||
for v1, but the shared helper makes a future `amy lives` cheap.
|
||||
|
||||
### Integration test scenarios
|
||||
1. Two follows live → bar shows higher-`current_participants` + "+1 live ›".
|
||||
2. `status=live` but `.m3u8` dead → treated offline (probe), not shown live.
|
||||
3. Post chat → 1311 with correct root `a` tag → appears; muted author's messages filtered.
|
||||
4. Zap stream → request carries 30311 `a` tag → receipt attributed to stream (recipient per decision).
|
||||
5. Planned flips live while app open → reminded user gets in-app snackbar.
|
||||
6. Ended stream with `recording` → VOD plays with normal seekable bar.
|
||||
7. live→ended mid-watch → end banner, composer disabled, "Play recording" offered, no auto-close.
|
||||
8. Open 2nd stream while watching → first's player+subscription torn down before second mounts.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional
|
||||
- [ ] Discover has a **Lives** section + client-side search (title/host/hashtag over cached 30311s).
|
||||
- [ ] Each **Following/Global** feed column shows a pinned live-now bar: one host (ranked by
|
||||
`current_participants`) + `+N live ›`; hidden when none live.
|
||||
- [ ] Opening a live shows a **full-window watch screen** (chosen host): live player (**no seek bar**,
|
||||
LIVE pill, stall→reconnect watchdog) left + kind-1311 chat right + header (title/host/roles/
|
||||
viewer count/status).
|
||||
- [ ] Live metadata (title/roles/viewer count/status) updates live as the 30311 is re-published.
|
||||
- [ ] User can **read + post** live chat; posts are 1311 with the correct root `a` tag; chat filters
|
||||
**muted/blocked** authors (private + public) and re-filters on live mute-list change.
|
||||
- [ ] Chat composer disabled-with-reason when: stream ended, logged out, or send in flight/failed
|
||||
(draft preserved).
|
||||
- [ ] User can **zap the stream** (recipient semantics defined); disabled-with-reason when no LN
|
||||
address / NWC not connected. (Per-message chat zap deferred to v1.5.)
|
||||
- [ ] **Planned** streams show a "starts in…" badge; overdue-planned relabeled/downranked.
|
||||
- [ ] **Ended** streams with `recording` play as **VOD** (seekable); ended-without-recording shows a
|
||||
non-playable "no recording" state everywhere it can appear.
|
||||
- [ ] Profile has a **Streams** tab (user's live + past streams).
|
||||
- [ ] Mid-watch `live→ended` shows an end banner, disables composer, offers recording; never auto-closes.
|
||||
- [ ] Streaming-URL change or mid-playback stall re-points/reconnects → offline+Retry, never a frozen frame.
|
||||
- [ ] Opening a 2nd stream cleanly tears down the first (both subscriptions + player) before mounting.
|
||||
- [ ] Direct naddr/nevent open resolves + fetch-first (loading→branch); unknown/not-found handled, no hang.
|
||||
|
||||
### Non-functional
|
||||
- [ ] Chat UI ≤ 4 list updates/sec (250ms bundler); list ≤ 500 rows; no recompose storm.
|
||||
- [ ] Live-bar recompose only when top-host/count changes (immutable `StateFlow` + `distinctUntilChanged`).
|
||||
- [ ] ≤ 1 network HEAD per `.m3u8` per 5 min; visible-only probing; one poll loop (not per-card).
|
||||
- [ ] Player mutations serialized; position ticks don't recompose header/chat; live mode reads no position.
|
||||
- [ ] Player + "Open in default player" both gated by a URL-scheme allowlist (https only; block
|
||||
`file://`/`smb://`/custom schemes); probe routed through the proxy-aware client; probe-on-intent,
|
||||
not on discovery.
|
||||
- [ ] Chat text sanitized (bidi controls escaped per repo rule / CVE-2021-42574; no remote auto-load
|
||||
abuse); participant `proof` verified before showing a claimed role (`hasValidProof`).
|
||||
- [ ] No new copyleft dependency (player stays kdroidFilter MIT).
|
||||
|
||||
### Quality gates
|
||||
- [ ] Unit tests: status order, freshness, ranking, offline-downgrade, overdue-planned, zombie-live,
|
||||
latest-per-address dedupe, concurrent-mutation sort stability.
|
||||
- [ ] `commons` JVM + iOS-purity, `desktopApp`, `amethyst`, `cli` compile; `spotlessApply` clean.
|
||||
- [ ] Manual testing sheet executed on macOS (+ Linux GStreamer note).
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Risks
|
||||
- **Live `.m3u8` handling + stall recovery** — highest risk; Phase 0 spike. Win/Mac have no native
|
||||
stall flag → app-level watchdog mandatory.
|
||||
- **Channel cache is new on Desktop** — first channel abstraction + first prune trigger; core-file change.
|
||||
- **Player singleton race** — fix before shipping (Mutex).
|
||||
- **Watch host** — no window-level host exists; pick (a)/(b)/(c) in Phase 0.
|
||||
- **Mute/block in chat** — known Desktop silent-no-op gap; must not regress.
|
||||
- **No OS scheduler** — "Remind me" is in-app/event-driven only.
|
||||
- **Linux runtime** — GStreamer required (documented in `desktopApp/build.gradle.kts`).
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals / Deferred
|
||||
Broadcasting; audio rooms / Nests (30312/10312); raids (`LiveActivitiesRaidEvent`); clips
|
||||
(`LiveActivitiesClipEvent`); zap-goal progress bar + zap-to-highlight (fast follow — `goal` tag
|
||||
parsed); OS-level/system-tray notifications; `amy` live verbs; PiP/mini live-player; NIP-53 `proof`
|
||||
signature *generation*; per-column bar on hashtag/list/search columns (v2).
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
**Blocking (resolve in Phase 0):**
|
||||
1. **Watch host** — (a) app-root overlay [recommended] / (b) separate `Window` / (c) single-pane +
|
||||
max-width column?
|
||||
2. **Live signal** — confirm `duration==0.0` on real live `.m3u8` across backends; is manifest
|
||||
`#EXT-X-ENDLIST` fetching worth it for precise ended, or rely on `status=ended`?
|
||||
3. **Stream-zap recipient** — single 30311 author, or honor NIP-53 zap splits / `p`-tag recipient?
|
||||
|
||||
**Scope (RESOLVED 2026-08-20):**
|
||||
4. Profile Streams tab → **kept in v1**.
|
||||
5. Planned "Remind me" → **badge-only in v1** (notify machinery deferred).
|
||||
6. Per-message chat zap + top-zappers header → **deferred to v1.5**.
|
||||
7. Watch host → **(a) app-root overlay host** (also blocking Q1 above).
|
||||
|
||||
**During work:**
|
||||
7. Chat windowing vs virtualization — confirm the 250ms-bundler + 500-cap path suffices (likely yes).
|
||||
8. Search — client-side for v1; NIP-50 relay search later?
|
||||
|
||||
---
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
- **Brainstorm:** [docs/brainstorms/2026-08-20-feat-desktop-live-media-brainstorm.md](../brainstorms/2026-08-20-feat-desktop-live-media-brainstorm.md)
|
||||
— carried forward: per-column live bar (one host + `+N`, viewers-ranked); lives folded into Discover
|
||||
+ search; full-window watch+chat; zaps in v1; live+planned+VOD; Streams tab; planned "Remind me";
|
||||
online HEAD-probe.
|
||||
|
||||
### Internal (verified file:line)
|
||||
- Quartz `nip53LiveActivities/{streaming/LiveActivitiesEvent, chat/LiveActivitiesChatMessageEvent, LiveStreamLike}.kt`, `streaming/tags/`.
|
||||
- commons `model/nip53LiveActivities/LiveActivitiesChannel.kt`, `nip53LiveActivities/{LiveActivityTopZappersAggregator, ui/StreamSystemCard}.kt`, `viewmodels/{FeedViewModel, LiveStreamTopZappersViewModel}.kt`, `ui/feeds/`, `ARCHITECTURE.md`, `build.gradle.kts` (`verifyKmpPurity`).
|
||||
- Android `home/dal/HomeLiveFilter.kt`, `home/live/{RenderLiveActivityBubble,LiveStatusIndicator}.kt`, `discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt:116-134`, `livestreams/dal/LiveStreamsFeedFilter.kt`, `note/types/{LiveActivity,LiveActivityChatMessage}.kt`, `chats/publicChannels/nip53LiveActivities/*`, `service/OnlineCheck.kt` (android-only, `LruCache`), `AccountViewModel.checkVideoIsOnline`.
|
||||
- Desktop `ui/deck/{DeckSidebar,DeckColumnType,DeckColumnContainer,DeckState,SinglePaneLayout}.kt`, `ui/{FeedScreen,ReadingColumn,UserProfileScreen,SearchScreen,ChatPane}.kt`, `followpacks/ui/DiscoverScreen.kt`, `ui/media/{DesktopVideoPlayer,VideoControls}.kt`, `service/media/GlobalMediaPlayer.kt`, `ui/NoteActions.kt`, `nwc/NwcPaymentHandler.kt`, `cache/DesktopLocalCache.kt` (`getAnyChannel`→null, `route()`, `addressableNotes`), `subscriptions/SubscriptionUtils.kt`, `Main.kt` (`DesktopScreen` per-column), `DesktopPreferences`.
|
||||
- Build: `gradle/libs.versions.toml` (`composemediaplayer = 0.11.4`, MIT).
|
||||
- Memory: `desktop_moderation_safety.md` (mute/block enforcement gap).
|
||||
|
||||
### External
|
||||
- NIP-53: https://github.com/nostr-protocol/nips/blob/master/53.md
|
||||
- zap.stream: https://github.com/v0l/zap.stream
|
||||
- kdroidFilter ComposeMediaPlayer: https://github.com/kdroidFilter/ComposeMediaPlayer (issues #244 buffered-ranges, #173 HLS Win/Mac, #77 403→blank)
|
||||
- RFC 8216 (HLS) `#EXT-X-ENDLIST`/`PLAYLIST-TYPE`: https://www.rfc-editor.org/rfc/rfc8216.html
|
||||
- Mux HLS ext tags: https://www.mux.com/articles/hls-ext-tags
|
||||
- Twitch chat rendering perf: https://blog.twitch.tv/en/2016/08/08/improving-chat-rendering-performance-1c0945b82764/
|
||||
- video.js live guide: https://videojs.com/guides/live/ · hls.js gap-controller: https://github.com/video-dev/hls.js/blob/master/src/controller/gap-controller.ts
|
||||
- Safer flow collection: https://medium.com/androiddevelopers/a-safer-way-to-collect-flows-from-android-uis-23080b1f8bda
|
||||
Reference in New Issue
Block a user