feat(commons): add NIP-53 LiveActivitySorting + plan/testing docs

Foundation for Desktop Live Media (NIP-53) consume+discover feature.

- LiveActivitySorting: pure, CLI-safe status-order / freshness / ranking
  helpers with a snapshot-map sort API so Android + Desktop order live
  streams identically and no comparator reads volatile state mid-sort
  (avoids the TimSort "contract violation" the Android filters guard against).
- Unit tests (green): status ordering, offline-live downgrade, 15-min
  live-bar freshness, overdue-planned detection, multi-key sort + tiebreaks,
  and stability under concurrent key mutation.
- Deepened plan (7 review agents) + brainstorm + full manual testing sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-08-24 11:40:09 +03:00
co-authored by Claude Opus 4.8
parent dad7fccaf2
commit 6614b0bd60
4 changed files with 886 additions and 0 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,158 @@
# Manual Testing Sheet — Desktop Live Media (NIP-53) v1
**Plan:** `docs/plans/2026-08-20-feat-desktop-live-media-plan.md`
**Branch:** `feat/desktop-live-media`
**Run:** `./gradlew :desktopApp:run`
This is the acceptance test for the whole v1 feature. Check items off as the corresponding phase
lands. Sections are ordered by implementation phase so you can start testing the earliest layers
before the watch screen exists.
**Legend:** ✅ pass · ❌ fail (note what happened) · ⏭️ blocked/not-yet-built
> **Good test streams:** open <https://zap.stream> in a browser to see who is currently live on
> Nostr, then find the same hosts in Amethyst. zap.stream streams are real kind-30311 events with
> live HLS `.m3u8` URLs and active kind-1311 chat — ideal for end-to-end testing. Have at least one
> **live**, one **planned/scheduled**, and one **ended-with-recording** stream in view.
---
## Pre-flight
- [ ] App launches, you are logged in with an account that **follows at least one host who streams**
(follow a couple of zap.stream regulars if not).
- [ ] Relays connected (no persistent offline banner).
- [ ] (Optional, for zap tests) an NWC wallet is connected under Wallet.
---
## Phase 1 — Data layer (no UI yet; verify via logs / debugging)
These have no user-facing surface; they're validated indirectly once Phase 2 UI exists. If you build
with debug logging, confirm:
- [ ] Kind **30311** events arriving from relays are stored (one entry per stream address; a
re-published 30311 **replaces** the old one, doesn't duplicate).
- [ ] Kind **1311** chat events attach to their stream's channel (grouped by the root `a` tag).
- [ ] Memory doesn't grow unbounded while a busy stream's chat streams in (channel chat is capped
at ~500 messages).
*(Unit tests already cover the ordering/freshness logic: `./gradlew :commons:jvmTest --tests
"*.LiveActivitySortingTest"` — should be green.)*
---
## Phase 2 — Discovery & the "live now" bar
### Discover → Lives
- [ ] Open **Discover**. A **Lives** section appears when ≥1 stream is known.
- [ ] Live streams show first (LIVE badge), then planned ("starts in…"), then ended.
- [ ] Within LIVE, streams with more of **your follows** participating rank higher; among those,
higher viewer count ranks higher.
- [ ] A `status=live` stream whose video is actually dead is **not** shown as live (sinks/hidden).
- [ ] Each card shows: thumbnail/image, title, host, LIVE/scheduled badge, viewer count when present.
- [ ] **Search box** filters the Lives grid live by **title**, **host name**, and **hashtag** as you type.
- [ ] Empty states read sensibly: no lives at all; search with no matches.
### Per-column "live now" bar
- [ ] Open a **Following** feed column. When a followed host is live, a compact pinned bar appears at
the **top of that column**, above the feed.
- [ ] The bar shows **one** host (the one with the most viewers) with a red LIVE indicator + title,
and a **"+N live ›"** affordance when more than one is live.
- [ ] Tapping **"+N live ›"** reveals/leads to the rest of the currently-live set.
- [ ] Open a **Global** feed column — the bar reflects the **global** live set (different from Following).
- [ ] When nobody in a column's audience is live, the bar is **hidden** (no empty bar).
- [ ] Scrolling the feed does not cause the bar to flicker/recompute visibly; scrolling is smooth.
---
## Phase 3 — Watch screen (player + chat + zap)
### Opening / layout
- [ ] Click a live card (from Discover or the bar). A **full-window watch screen** opens: video on the
**left**, live chat on the **right**, host/metadata below/around.
- [ ] Header shows: title, host (avatar+name), participant roles (Host/Speaker/…), viewer count, and a
**LIVE** status pill.
- [ ] Closing the watch screen returns to where you were (deck/single-pane) and **stops playback**.
### Video playback (live)
- [ ] The live stream plays (audio + video).
- [ ] **No seek bar** is shown in live mode (a "● LIVE" pill instead of a scrubber).
- [ ] Play/pause and volume work; fullscreen works.
- [ ] Pull your network briefly / stop the source: the player shows **"Reconnecting…"** (not a frozen
frame), then recovers when the stream returns, or shows an **offline + Retry** state if it stays down.
- [ ] A stream that is offline when you open it shows an **offline placeholder**, never an endless spinner.
### Video playback (VOD / recording)
- [ ] Open an **ended** stream that has a recording. It plays the **recording** with a **normal seekable**
seek bar (VOD mode), scrubbing works.
- [ ] An **ended stream with no recording** shows a "Stream ended — no recording" state (non-playable),
both in the grid and if opened.
### Live chat (read)
- [ ] Chat messages (kind 1311) for **this** stream appear on the right, newest at the bottom.
- [ ] The list auto-scrolls to the newest message **only while you're at the bottom**.
- [ ] Scroll up: auto-scroll pauses and a **"↓ N new messages"** pill appears; clicking it snaps to bottom.
- [ ] Hovering the chat pauses auto-scroll (mouse-first); moving away resumes.
- [ ] Messages from **muted/blocked** users do **not** appear; muting someone mid-stream hides their
existing messages immediately.
- [ ] A very busy stream stays smooth (no UI jank/freeze); messages may batch slightly (expected).
### Live chat (post)
- [ ] Type a message and send. It appears in the chat (as a kind-1311 with the correct stream `a` tag),
visible to other clients (verify on zap.stream if possible).
- [ ] Sending with **no relays connected** / **logged out** shows a clear disabled reason, not a silent failure.
- [ ] When the stream **ends** while you're watching: an "ended" banner shows, the composer is **disabled**
("stream ended"), chat stays **readable**, and a **"Play recording"** action appears if a recording exists.
The window does **not** auto-close.
### Zapping the stream
- [ ] A **Zap** action is available for the stream. Zapping opens the amount dialog and sends via NWC.
- [ ] Zap is **disabled with a reason** when the recipient has no lightning address, or when no NWC wallet
is connected (routes you to connect).
- [ ] Zap receipt is attributed to the **stream** (30311), and the amount reflects in the stream's totals.
*(Per-message chat zap + a top-zappers leaderboard are **deferred to v1.5** — not expected here.)*
### Mid-watch transitions & re-entry
- [ ] Host edits the title/adds participants mid-stream (30311 re-published): the header updates live.
- [ ] While watching one stream, open a **second** stream from the bar/Discover: the first tears down
cleanly (video + chat stop) and the second starts — no double audio, no leaked chat.
- [ ] Open a stream via a **direct naddr/nevent link**: it resolves, shows a loading state, then the live/
ended/offline branch. An unknown/not-found address shows a clear message (never hangs).
---
## Phase 4 — Profile Streams tab & planned streams
- [ ] Open a streaming host's **profile**. A **Streams** tab lists their streams (live + past).
- [ ] A live stream in the tab is marked LIVE and opens the watch screen; a past one with a recording
plays VOD.
- [ ] A **planned** stream anywhere shows a **"starts in…" badge**. *(A "Remind me" action is **deferred**
— not expected in v1.)*
- [ ] A planned stream whose start time passed long ago and never went live is relabeled/downranked
(not stuck showing "starts in -3h").
---
## Cross-cutting / regression
- [ ] **Security:** the app only plays `https` stream URLs; a stream with a `file://`/`smb://`/custom-scheme
URL is refused (no OS handler is invoked, incl. the "open in default player" fallback).
- [ ] **Privacy:** the app does not probe stream URLs you never chose to interact with beyond what's needed
to show live/offline for visible cards (no drive-by HEAD storm to every stream in the cache).
- [ ] **Chat safety:** a chat message containing bidirectional/control unicode renders without reordering
surrounding UI text.
- [ ] Existing feed video (NowPlayingBar) still works; opening a live takes over playback and closing it
leaves the player in a clean state (no stale live position leaking into later feed/VOD video).
- [ ] No new crashes; `./gradlew :commons:jvmTest :desktopApp:test` green; `./gradlew spotlessApply` clean.
---
## Platform notes
- **macOS** (AVFoundation): primary test target.
- **Linux** (GStreamer): must be installed at runtime; live HLS reconnection behaviour differs — spot-check.
- **Windows** (Media Foundation): native live HLS is weakest; verify the stall watchdog/reconnect carries it.
---
## Known v1 limitations (by design — not bugs)
- No broadcasting / going live.
- No audio rooms / Nests, raids, or clips.
- No per-message chat zap or top-zappers leaderboard (v1.5).
- No OS-level notifications / "Remind me" for planned streams (badge only).
- Live bar only on **Following** and **Global** columns (not hashtag/list/search columns).
@@ -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