mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
co-authored by
Claude Opus 4.8
parent
dad7fccaf2
commit
6614b0bd60
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.nip53LiveActivities
|
||||
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Pure, CLI-safe ranking + freshness logic for NIP-53 live activities, shared by Amethyst Android
|
||||
* and Amethyst Desktop so both platforms order live streams identically (no drift).
|
||||
*
|
||||
* Everything here operates on **primitives / pre-snapshotted values** rather than reading mutable
|
||||
* event or online-checker state. This is deliberate: the sort keys (status-after-online-check and
|
||||
* `current_participants`) can be mutated by background work mid-sort, and reading them lazily inside
|
||||
* a comparator makes the ordering unstable — TimSort then throws
|
||||
* *"Comparison method violates its general contract!"*. Callers must snapshot the keys into a
|
||||
* [LiveActivityRank] once, before sorting.
|
||||
*/
|
||||
object LiveActivitySorting {
|
||||
/** Status ordering used across the discover feed, the live bar and profile streams. */
|
||||
const val ORDER_LIVE = 2
|
||||
const val ORDER_PLANNED = 1
|
||||
const val ORDER_ENDED = 0
|
||||
|
||||
/** How recently a `status=live` 30311 must have been re-published to count for the live bar. */
|
||||
const val LIVE_BAR_FRESHNESS_SECONDS = 15 * 60L
|
||||
|
||||
/** Grace after a planned `starts` before it is treated as "overdue / never went live". */
|
||||
const val OVERDUE_PLANNED_GRACE_SECONDS = 60 * 60L
|
||||
|
||||
/**
|
||||
* Maps a live-activity status to a sort bucket. A `status=live` stream whose `.m3u8` is known to
|
||||
* be offline right now ([isOfflineNow]) is downgraded to [ORDER_ENDED] so dead streams sink.
|
||||
*/
|
||||
fun statusOrder(
|
||||
status: StatusTag.STATUS?,
|
||||
isOfflineNow: Boolean = false,
|
||||
): Int =
|
||||
when (status) {
|
||||
StatusTag.STATUS.LIVE -> if (isOfflineNow) ORDER_ENDED else ORDER_LIVE
|
||||
StatusTag.STATUS.PLANNED -> ORDER_PLANNED
|
||||
StatusTag.STATUS.ENDED -> ORDER_ENDED
|
||||
null -> ORDER_ENDED
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a stream should surface in the per-column "live now" bar: it is `status=live`, its
|
||||
* `.m3u8` is not known-offline, and the 30311 was (re)published within [freshnessSeconds]. A host
|
||||
* is expected to re-publish the 30311 continuously while live, so a stale one is a zombie.
|
||||
*/
|
||||
fun isLiveAndFresh(
|
||||
status: StatusTag.STATUS?,
|
||||
createdAt: Long,
|
||||
now: Long = TimeUtils.now(),
|
||||
isOfflineNow: Boolean = false,
|
||||
freshnessSeconds: Long = LIVE_BAR_FRESHNESS_SECONDS,
|
||||
): Boolean = status == StatusTag.STATUS.LIVE && !isOfflineNow && createdAt >= now - freshnessSeconds
|
||||
|
||||
/**
|
||||
* True when a `status=planned` stream's start time has passed by more than the grace window and
|
||||
* it never flipped to live — used to relabel / downrank stale "starts in…" cards.
|
||||
*/
|
||||
fun isOverduePlanned(
|
||||
status: StatusTag.STATUS?,
|
||||
startsAt: Long?,
|
||||
now: Long = TimeUtils.now(),
|
||||
graceSeconds: Long = OVERDUE_PLANNED_GRACE_SECONDS,
|
||||
): Boolean = status == StatusTag.STATUS.PLANNED && startsAt != null && startsAt < now - graceSeconds
|
||||
|
||||
/**
|
||||
* A pre-snapshotted set of comparator keys for one live activity. Build one per item **before**
|
||||
* sorting; never read live event/online state inside the comparator.
|
||||
*/
|
||||
data class LiveActivityRank(
|
||||
val statusOrder: Int,
|
||||
val followParticipants: Int,
|
||||
val totalParticipants: Int,
|
||||
val startOrCreated: Long,
|
||||
val idHex: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Descending comparator (best first): live > planned > ended, then more participating follows,
|
||||
* then more total participants, then more recent start/creation, then id for a stable tiebreak.
|
||||
*/
|
||||
val RANK_DESCENDING: Comparator<LiveActivityRank> =
|
||||
compareByDescending<LiveActivityRank> { it.statusOrder }
|
||||
.thenByDescending { it.followParticipants }
|
||||
.thenByDescending { it.totalParticipants }
|
||||
.thenByDescending { it.startOrCreated }
|
||||
.thenByDescending { it.idHex }
|
||||
|
||||
/**
|
||||
* Snapshots each item's rank **once** via [rankOf], then sorts. Because the keys are captured up
|
||||
* front, [rankOf] may read volatile state (online cache, `current_participants`) safely — the
|
||||
* comparator only ever sees the immutable snapshot, so TimSort's contract holds.
|
||||
*/
|
||||
fun <T> sortDescending(
|
||||
items: Collection<T>,
|
||||
rankOf: (T) -> LiveActivityRank,
|
||||
): List<T> {
|
||||
val snapshot = items.associateWith(rankOf)
|
||||
return items.sortedWith { a, b -> RANK_DESCENDING.compare(snapshot.getValue(a), snapshot.getValue(b)) }
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.nip53LiveActivities
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivitySorting.LiveActivityRank
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class LiveActivitySortingTest {
|
||||
private val now = 1_000_000L
|
||||
|
||||
@Test
|
||||
fun statusOrderRanksLiveOverPlannedOverEnded() {
|
||||
assertEquals(LiveActivitySorting.ORDER_LIVE, LiveActivitySorting.statusOrder(StatusTag.STATUS.LIVE))
|
||||
assertEquals(LiveActivitySorting.ORDER_PLANNED, LiveActivitySorting.statusOrder(StatusTag.STATUS.PLANNED))
|
||||
assertEquals(LiveActivitySorting.ORDER_ENDED, LiveActivitySorting.statusOrder(StatusTag.STATUS.ENDED))
|
||||
assertEquals(LiveActivitySorting.ORDER_ENDED, LiveActivitySorting.statusOrder(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineLiveIsDowngradedToEnded() {
|
||||
assertEquals(
|
||||
LiveActivitySorting.ORDER_ENDED,
|
||||
LiveActivitySorting.statusOrder(StatusTag.STATUS.LIVE, isOfflineNow = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isLiveAndFreshRespectsFreshnessWindow() {
|
||||
// published just now -> fresh
|
||||
assertTrue(
|
||||
LiveActivitySorting.isLiveAndFresh(StatusTag.STATUS.LIVE, createdAt = now, now = now),
|
||||
)
|
||||
// published 20 min ago -> stale (default 15-min window)
|
||||
assertFalse(
|
||||
LiveActivitySorting.isLiveAndFresh(StatusTag.STATUS.LIVE, createdAt = now - 20 * 60, now = now),
|
||||
)
|
||||
// fresh but offline -> excluded
|
||||
assertFalse(
|
||||
LiveActivitySorting.isLiveAndFresh(StatusTag.STATUS.LIVE, createdAt = now, now = now, isOfflineNow = true),
|
||||
)
|
||||
// planned is never "live and fresh"
|
||||
assertFalse(
|
||||
LiveActivitySorting.isLiveAndFresh(StatusTag.STATUS.PLANNED, createdAt = now, now = now),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overduePlannedDetectedAfterGrace() {
|
||||
// starts 2h ago, still planned -> overdue
|
||||
assertTrue(
|
||||
LiveActivitySorting.isOverduePlanned(StatusTag.STATUS.PLANNED, startsAt = now - 2 * 60 * 60, now = now),
|
||||
)
|
||||
// starts in the future -> not overdue
|
||||
assertFalse(
|
||||
LiveActivitySorting.isOverduePlanned(StatusTag.STATUS.PLANNED, startsAt = now + 60 * 60, now = now),
|
||||
)
|
||||
// no start time -> not overdue
|
||||
assertFalse(
|
||||
LiveActivitySorting.isOverduePlanned(StatusTag.STATUS.PLANNED, startsAt = null, now = now),
|
||||
)
|
||||
// live is never "overdue planned"
|
||||
assertFalse(
|
||||
LiveActivitySorting.isOverduePlanned(StatusTag.STATUS.LIVE, startsAt = now - 2 * 60 * 60, now = now),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sortOrdersByStatusThenFollowsThenTotalThenTime() {
|
||||
val endedFresh = rank(LiveActivitySorting.ORDER_ENDED, follows = 5, total = 5, time = now, id = "a")
|
||||
val liveFewFollows = rank(LiveActivitySorting.ORDER_LIVE, follows = 1, total = 1, time = now - 100, id = "b")
|
||||
val liveManyFollows = rank(LiveActivitySorting.ORDER_LIVE, follows = 3, total = 3, time = now - 200, id = "c")
|
||||
val planned = rank(LiveActivitySorting.ORDER_PLANNED, follows = 9, total = 9, time = now, id = "d")
|
||||
|
||||
val items = listOf(endedFresh, liveFewFollows, planned, liveManyFollows)
|
||||
val sorted = LiveActivitySorting.sortDescending(items) { it }
|
||||
|
||||
// live (most follows) > live (fewer) > planned > ended
|
||||
assertEquals(listOf(liveManyFollows, liveFewFollows, planned, endedFresh), sorted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sortBreaksTiesByTotalThenTimeThenId() {
|
||||
val a = rank(LiveActivitySorting.ORDER_LIVE, follows = 2, total = 10, time = now, id = "aaa")
|
||||
val b = rank(LiveActivitySorting.ORDER_LIVE, follows = 2, total = 20, time = now, id = "bbb")
|
||||
val c = rank(LiveActivitySorting.ORDER_LIVE, follows = 2, total = 20, time = now + 50, id = "ccc")
|
||||
|
||||
val sorted = LiveActivitySorting.sortDescending(listOf(a, b, c)) { it }
|
||||
// higher total first (b,c over a); newer time breaks b vs c
|
||||
assertEquals(listOf(c, b, a), sorted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sortIsStableUnderConcurrentKeyMutation() {
|
||||
// Simulate a background online-check flipping a stream's status mid-sort: rankOf reads a
|
||||
// volatile source, but sortDescending snapshots once, so no TimSort contract violation.
|
||||
val ids = (0 until 200).map { it.toString().padStart(3, '0') }
|
||||
var flips = 0
|
||||
val sorted =
|
||||
LiveActivitySorting.sortDescending(ids) { id ->
|
||||
// Alternate the status bucket every read; if the comparator read this lazily it would
|
||||
// be inconsistent and throw. Snapshotting protects us.
|
||||
flips++
|
||||
val order = if (flips % 2 == 0) LiveActivitySorting.ORDER_LIVE else LiveActivitySorting.ORDER_ENDED
|
||||
LiveActivityRank(order, 0, 0, now, id)
|
||||
}
|
||||
assertEquals(ids.size, sorted.size)
|
||||
}
|
||||
|
||||
private fun rank(
|
||||
statusOrder: Int,
|
||||
follows: Int,
|
||||
total: Int,
|
||||
time: Long,
|
||||
id: String,
|
||||
) = LiveActivityRank(statusOrder, follows, total, time, id)
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
Reference in New Issue
Block a user