Merge pull request #3976 from nrobi144/feat/desktop-live-media

feat(desktop): NIP-53 live streaming — consume & discover
This commit is contained in:
Vitor Pamplona
2026-08-24 09:19:34 -04:00
committed by GitHub
19 changed files with 2208 additions and 33 deletions
@@ -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)) }
}
}
@@ -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,
@@ -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()
@@ -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()) {
@@ -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
@@ -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).
*/
@@ -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.
*
@@ -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 -> {
@@ -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)
}
}
@@ -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)),
)
}
@@ -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
}
}
@@ -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") }
}
}
@@ -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"
}
}
@@ -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,
)
}
}
@@ -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