diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivitySorting.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivitySorting.kt new file mode 100644 index 0000000000..07f99afc53 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivitySorting.kt @@ -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 = + compareByDescending { 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 sortDescending( + items: Collection, + rankOf: (T) -> LiveActivityRank, + ): List { + val snapshot = items.associateWith(rankOf) + return items.sortedWith { a, b -> RANK_DESCENDING.compare(snapshot.getValue(a), snapshot.getValue(b)) } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivitySortingTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivitySortingTest.kt new file mode 100644 index 0000000000..a18caf4074 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivitySortingTest.kt @@ -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) +} diff --git a/desktopApp/plans/2026-08-20-live-media-manual-testing.md b/desktopApp/plans/2026-08-20-live-media-manual-testing.md new file mode 100644 index 0000000000..8a4ddf4584 --- /dev/null +++ b/desktopApp/plans/2026-08-20-live-media-manual-testing.md @@ -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 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). diff --git a/docs/plans/2026-08-20-feat-desktop-live-media-plan.md b/docs/plans/2026-08-20-feat-desktop-live-media-plan.md new file mode 100644 index 0000000000..0fd234cab2 --- /dev/null +++ b/docs/plans/2026-08-20-feat-desktop-live-media-plan.md @@ -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`) 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` 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` + +> `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"]` 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` 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