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..8a18b6b63d --- /dev/null +++ b/desktopApp/plans/2026-08-20-live-media-manual-testing.md @@ -0,0 +1,150 @@ +# Manual Testing Sheet — Desktop Live Media (NIP-53) v1 + +**Plan:** `docs/plans/2026-08-20-feat-desktop-live-media-plan.md` +**Branch / worktree:** `feat/desktop-live-media` · `.claude/worktrees/feat-desktop-live-media` +**Run:** `./gradlew :desktopApp:run` + +Acceptance test for the whole v1 feature. Each section is tagged with its current build state so you +know what will actually work today: + +- **🟢 LIVE** — built & compiling; test it now. +- **🟡 PARTIAL** — built but with a known gap (called out inline). +- **⚪ PENDING** — not built yet; skip until its commit lands. + +**Legend for results:** ✅ pass · ❌ fail (write what happened) · ⏭️ skipped/blocked + +--- + +## Setup (do once) +- [ ] Open **** in a browser — this shows who is live on Nostr right now with real + kind-30311 streams + kind-1311 chat. Keep it handy as your source of truth. +- [ ] Log into Amethyst Desktop with an account that **follows ≥1 host who streams** (follow a couple of + the zap.stream front-page hosts if not). +- [ ] Confirm relays are connected (no persistent "offline" banner). +- [ ] (For later zap tests) connect an NWC wallet under **Wallet**. + +> **Tip:** live streams come and go. If a section says "nobody is live," check zap.stream — if the front +> page is also empty, wait for a stream to start rather than recording a failure. + +--- + +## 1. Discover → "Live now" 🟢 LIVE + +Open the **Discover** destination. Scroll to the **LIVE NOW** section (below the featured pack hero). + +| # | Step | Expected | Result | +|---|------|----------|--------| +| 1.1 | Open Discover with ≥1 stream live on the network | A **LIVE NOW** section appears with a red dot header + a search box + a grid of cards | | +| 1.2 | Look at card order | **Live** streams first, then **planned** ("starts in…"), then ended; within live, streams where **more of your follows** participate rank higher, then higher **viewer count** | | +| 1.3 | Inspect a live card | Shows thumbnail image, a red **LIVE** badge, the **title**, the **host** name, and **"N watching"** when the host reports a viewer count | | +| 1.4 | Inspect a planned card | Shows a **"in 2h / in 30m / SCHEDULED"** badge instead of LIVE | | +| 1.5 | Type a host's name in the search box | Grid filters live to matching streams as you type | | +| 1.6 | Type part of a stream **title** | Filters to matching titles | | +| 1.7 | Type a **hashtag** that a live stream uses (e.g. `bitcoin`) | Filters to streams tagged with it | | +| 1.8 | Type gibberish (`zzzzz`) | Shows **"No live streams match "zzzzz.""** (grid empty, section still visible) | | +| 1.9 | Clear the search | Full ranked grid returns | | +| 1.10 | Open Discover when **nothing** is live network-wide | The LIVE NOW section is **absent** (not an empty box) | | +| 1.11 | **Known gap:** click a card | Nothing happens yet — the watch screen isn't wired (see §3). Not a bug today. | | + +--- + +## 2. Per-column "live now" bar 🟢 LIVE + +| # | Step | Expected | Result | +|---|------|----------|--------| +| 2.1 | Open a **Following** feed column while a **followed** host is live | A compact pinned bar sits at the **top of that column**, above the feed: red dot + **" is live"** | | +| 2.2 | Have **2+** followed hosts live at once | The bar shows the **most-watched** one, plus a **"+N live ›"** link | | +| 2.3 | Click **"+N live ›"** | A dropdown lists all currently-live streams in scope; each entry is clickable | | +| 2.4 | Open a **Global** feed column | The bar reflects the **global** live set (typically different/larger than Following) | | +| 2.5 | Scroll the feed | The bar stays **pinned** at the top (doesn't scroll away) and the feed scrolls smoothly beneath it | | +| 2.6 | Open a column where **nobody in scope** is live | **No bar** is shown (Following bar hidden when no followed host is live) | | +| 2.7 | Open a **hashtag / list / search / notifications** column | **No bar** (by design — v1 scopes the bar to Following + Global only) | | +| 2.8 | **Known gap:** click the bar / a dropdown entry | No-op today (watch screen pending, §3) | | + +--- + +## 3. Watch screen — player + chat + zap ⚪ PENDING + +*Not built yet. These are the target checks for when the watch commit lands; skip for now.* + +### Open / layout +- [ ] Clicking a live card or the bar opens a **full-window** watch screen: video **left**, chat **right**, host/metadata below. +- [ ] Header shows title, host (avatar + name), participant roles, viewer count, and a **LIVE** pill. +- [ ] Closing returns to the prior view and **stops playback**. + +### Video (live) +- [ ] The live stream plays (audio + video). +- [ ] **No seek bar** in live mode (a "● LIVE" pill instead). +- [ ] Play/pause, volume, fullscreen work. +- [ ] Kill the source briefly → **"Reconnecting…"** (not a frozen frame) → recovers, or an **offline + Retry** state if it stays down. +- [ ] Opening an already-offline stream → offline placeholder, never an endless spinner. + +### Video (VOD / recording) +- [ ] An **ended** stream with a recording plays the recording with a **normal seekable** bar. +- [ ] An ended stream with **no recording** shows "Stream ended — no recording" (non-playable). + +### Chat (read) +- [ ] Kind-1311 messages for this stream appear, newest at bottom. +- [ ] Auto-scrolls to newest **only while at the bottom**; scrolling up shows a **"↓ N new"** pill. +- [ ] Hovering chat pauses auto-scroll; leaving resumes. +- [ ] **Muted/blocked** users' messages don't appear; muting mid-stream hides existing ones immediately. +- [ ] A busy stream stays smooth (messages may batch slightly). + +### Chat (post) +- [ ] Sending posts a kind-1311 with the correct stream `a` tag (verify it shows on zap.stream). +- [ ] Composer is **disabled with a reason** when logged out / no relays / stream ended (draft preserved). + +### Zap +- [ ] **Zap** the stream → amount dialog → sends via NWC; receipt attributed to the 30311. +- [ ] Zap **disabled with reason** when the host has no lightning address or no NWC wallet is connected. +- [ ] *(Per-message chat zap + top-zappers leaderboard are v1.5 — not expected.)* + +### Lifecycle / re-entry +- [ ] Host edits title/participants mid-stream → header updates live. +- [ ] Stream ends mid-watch → "ended" banner, composer disabled, "Play recording" offered; window does **not** auto-close. +- [ ] Opening a **second** stream tears down the first cleanly (no double audio, no leaked chat). +- [ ] Opening via a direct **naddr/nevent** link resolves + loads; unknown → clear message, no hang. + +--- + +## 4. Profile "Streams" tab ⚪ PENDING +- [ ] A streaming host's **profile** has a **Streams** tab listing their live + past streams. +- [ ] A live entry opens the watch screen; a past one with a recording plays VOD. + +## 5. Planned streams 🟡 PARTIAL +- [ ] Planned streams show a **"starts in…"** badge in the Discover grid. 🟢 (works now) +- [ ] A planned stream whose start passed long ago and never went live is relabeled/downranked. ⚪ (pending online/overdue wiring) +- [ ] *(No "Remind me" in v1 — badge only, by design.)* + +## 6. Online-probe / dead streams ⚪ PENDING +- [ ] A `status=live` stream whose `.m3u8` is actually dead is **not** shown as live (sinks/hidden). + *Today all `status=live` are treated as online — this downgrade is not wired yet.* + +--- + +## 7. Cross-cutting / security & regression 🟡 PARTIAL (mostly pending) +- [ ] ⚪ App only plays `https` stream URLs; `file://`/`smb://`/custom-scheme URLs are refused (incl. the "open in default player" fallback). +- [ ] ⚪ No drive-by HEAD probing of every cached stream URL (probe only what's needed for visible cards). +- [ ] ⚪ Chat messages with bidirectional/control unicode render without reordering surrounding UI. +- [ ] 🟢 Existing feed video (NowPlayingBar) still works; existing feeds/Discover unaffected by the new section. +- [ ] 🟢 `./gradlew :commons:jvmTest --tests "*.LiveActivitySortingTest"` passes. +- [ ] ⚪ Full build green: `./gradlew :desktopApp:compileKotlin :amethyst:compileDebugKotlin` + `spotlessApply` clean. + +--- + +## Platform notes +- **macOS** (AVFoundation): primary target for the video tests. +- **Linux** (GStreamer): must be installed at runtime; live-HLS reconnection differs — spot-check when watch lands. +- **Windows** (Media Foundation): weakest native live HLS — the stall watchdog/reconnect must carry it. + +## Known v1 limitations (by design — not bugs) +No broadcasting; no audio rooms / raids / clips; no per-message chat zap or leaderboard (v1.5); no OS +notifications / "Remind me" (badge only); live bar only on Following + Global columns. + +--- + +### Quick "test right now" path (today's build) +1. `./gradlew :desktopApp:run`, log in, ensure ≥1 stream is live (check zap.stream). +2. **Discover → LIVE NOW**: see the grid, confirm ranking (§1.2), try search (§1.5–1.9). +3. **Home → Following** and **Global**: confirm the pinned live bar + "+N live ›" (§2). +4. Everything in §3–§7 marked ⚪/🟡-pending is expected **not** to work yet — that's the next build. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index a8b783160d..9d20aebca5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -2359,6 +2359,23 @@ fun MainContent( com.vitorpamplona.amethyst.desktop.ui.media .GlobalFullscreenOverlay() + // Full-window NIP-53 live watch overlay (player + chat), opened from any live surface + // via LiveWatchController. + val watchAddress by com.vitorpamplona.amethyst.desktop.ui.live.LiveWatchController.current + .collectAsState() + watchAddress?.let { addr -> + com.vitorpamplona.amethyst.desktop.ui.live.LiveWatchScreen( + address = addr, + cache = localCache, + relayManager = relayManager, + account = account, + onClose = { + com.vitorpamplona.amethyst.desktop.ui.live.LiveWatchController + .close() + }, + ) + } + // Snackbar for zap feedback SnackbarHost( hostState = snackbarHostState, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 516ed7ad80..31ea0a5ca9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.UserContext import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollTallyPolicy import com.vitorpamplona.amethyst.commons.service.nwc.NwcPaymentTracker import com.vitorpamplona.quartz.nip01Core.core.Address @@ -57,6 +58,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEv import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -65,6 +68,7 @@ import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -90,6 +94,16 @@ class DesktopLocalCache : ICacheProvider { val users = LargeSoftCache() val notes = LargeSoftCache() val addressableNotes = LargeSoftCache() + + /** + * NIP-53 live-stream channels (kind 30311), keyed by address value. This is the FIRST channel + * cache on Desktop (the base app has none — [getAnyChannel] historically returned null). It is a + * strong-ref [LargeCache] (not soft) because a channel owns its live chat (kind 1311) notes and + * must survive GC while a stream is on-screen; chat growth is bounded instead by + * [LiveActivitiesChannel.pruneOldMessages] (cap 500), triggered from [consumeLiveActivityChat]. + */ + val liveChatChannels = LargeCache() + private val deletedEvents = ConcurrentHashMap.newKeySet() /** NIP-hints index accumulated from consumed events (event/address/pubkey → relay). */ @@ -130,6 +144,8 @@ class DesktopLocalCache : ICacheProvider { val metadataVersion: StateFlow = _metadataVersion.asStateFlow() companion object { + /** Cap on retained kind-1311 chat messages per live channel (matches [Channel.pruneOldMessages]). */ + const val MAX_CHAT_MESSAGES_PER_CHANNEL = 500 } /** Index of notes by author pubkey — for fast metadata invalidation */ @@ -279,8 +295,13 @@ class DesktopLocalCache : ICacheProvider { ): Boolean { if (!wasVerified && !justVerify(event)) return false val consumed = route(event, relay) - // Write-through to local store, but skip if event came from local store (hydration) - if (consumed && relay != com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore.LOCAL_RELAY_URL) { + // Write-through to local store, but skip if event came from local store (hydration). Live + // chat (kind 1311) is deliberately NOT persisted: a busy stream's chat is unbounded and + // replaying it on next launch is pointless (the stream is over) — only the 30311s hydrate. + if (consumed && + relay != com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore.LOCAL_RELAY_URL && + event !is LiveActivitiesChatMessageEvent + ) { localRelayStore?.enqueue(event) } return consumed @@ -364,6 +385,14 @@ class DesktopLocalCache : ICacheProvider { consumePollResponse(event, relay) } + is LiveActivitiesEvent -> { + consumeLiveActivity(event, relay) + } + + is LiveActivitiesChatMessageEvent -> { + consumeLiveActivityChat(event, relay) + } + else -> { false } @@ -797,6 +826,88 @@ class DesktopLocalCache : ICacheProvider { return true } + // ----- NIP-53 live activities (kind 30311 streams + kind 1311 chat) ----- + + /** + * Bumped whenever a live-stream (30311) is added/updated so the Lives discovery grid and the + * per-column "live now" bar can recompute their snapshot. Chat (1311) does NOT bump this — chat + * updates flow through the channel's own notes flow, and a new message never changes stream + * ranking, so bumping here would cause needless full-grid recomputes on busy streams. + */ + private val _liveActivityVersion = MutableStateFlow(0L) + val liveActivityVersion: StateFlow = _liveActivityVersion.asStateFlow() + + fun getOrCreateLiveActivityChannel(address: Address): LiveActivitiesChannel = + liveChatChannels.getOrCreate(address.toValue()) { + LiveActivitiesChannel(address) + } + + /** + * Consumes a kind 30311 live streaming event. Replaceable, so the newest per-address copy wins + * (stored in [addressableNotes] like other addressables) and is attached to the address's + * [LiveActivitiesChannel]. Returns true so it write-throughs to the local relay store (30311s are + * cheap and bounded — hydrating them on next launch shows the last-known streams instantly). + */ + private fun consumeLiveActivity( + event: LiveActivitiesEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val address = event.address() + val addressableNote = getOrCreateAddressableNote(address) + + val existing = addressableNote.event + if (existing != null && existing.createdAt >= event.createdAt) return false + + val author = getOrCreateUser(event.pubKey) + addressableNote.loadEvent(event, author, emptyList()) + relay?.let { addressableNote.addRelay(it) } + + val channel = getOrCreateLiveActivityChannel(address) + channel.updateChannelInfo(author, event, addressableNote) + + _liveActivityVersion.value++ + return true + } + + /** + * Consumes a kind 1311 live chat message and attaches it to its stream's channel (by the root + * `a` tag). The channel's chat is capped at 500 newest messages via [pruneOldMessages] so a busy + * stream can't grow memory unbounded. Unlike 30311, chat is NOT persisted to the local relay + * store (see [consume]) to avoid unbounded chat replay on next launch. + */ + private fun consumeLiveActivityChat( + event: LiveActivitiesChatMessageEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + + val author = getOrCreateUser(event.pubKey) + note.loadEvent(event, author, emptyList()) + trackNoteAuthor(note, event.pubKey) + relay?.let { note.addRelay(it) } + + val activityAddress = event.activityAddress() ?: return true + val channel = getOrCreateLiveActivityChannel(activityAddress) + channel.addNote(note, relay) + if (channel.notes.size() > MAX_CHAT_MESSAGES_PER_CHANNEL) { + channel.pruneOldMessages() + } + return true + } + + /** + * Snapshot every known live-stream channel that currently carries a 30311. Driven by + * [liveActivityVersion] for reactive recomputation (mirrors [snapshotFollowPacks]). + */ + fun snapshotLiveActivities(): List { + val out = mutableListOf() + liveChatChannels.forEach { _, channel -> + if (channel.info != null) out.add(channel) + } + return out + } + /** * Snapshot all 39089 (FollowListEvent) addressable notes currently in cache. * Use [followPackVersion] to drive reactive recomputation. @@ -921,8 +1032,13 @@ class DesktopLocalCache : ICacheProvider { // ----- Channel operations ----- override fun getAnyChannel(note: Note): Channel? { - // Desktop doesn't support channels yet - return null + // Only NIP-53 live channels exist on Desktop today. Resolve a 1311 chat message or a 30311 + // stream note back to its channel; everything else has no channel. + return when (val event = note.event) { + is LiveActivitiesChatMessageEvent -> event.activityAddress()?.let { liveChatChannels.get(it.toValue()) } + is LiveActivitiesEvent -> liveChatChannels.get(event.address().toValue()) + else -> null + } } // ----- Deletion tracking ----- @@ -1015,6 +1131,7 @@ class DesktopLocalCache : ICacheProvider { users.clear() notes.clear() addressableNotes.clear() + liveChatChannels.clear() deletedEvents.clear() _followedUsers.value = emptySet() followerCounts.clear() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/DiscoverScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/DiscoverScreen.kt index d362815d23..13a34faf40 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/DiscoverScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/DiscoverScreen.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.desktop.followpacks.subscribeMetadataFor import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.amethyst.desktop.ui.live.LivesSection import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import kotlinx.coroutines.launch import java.awt.Toolkit @@ -86,6 +87,10 @@ fun DiscoverScreen( onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, @Suppress("UNUSED_PARAMETER") onZapFeedback: (ZapFeedback) -> Unit, + onOpenLive: (String) -> Unit = { + com.vitorpamplona.amethyst.desktop.ui.live.LiveWatchController + .open(it) + }, modifier: Modifier = Modifier, ) { val featured by state.featuredPack.collectAsState() @@ -193,6 +198,15 @@ fun DiscoverScreen( } } + // ---- Live now (NIP-53) ---- + Spacer(Modifier.height(24.dp)) + LivesSection( + cache = cache, + relayManager = relayManager, + followSet = followedAuthors.authors, + onOpenLive = onOpenLive, + ) + // ---- Topic chips ---- val chipTags = remember(current) { current?.let { state.hashtagsFor(it) } ?: emptyList() } if (chipTags.isNotEmpty()) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt index 7c0eddbbd9..f1274a4e0e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt @@ -73,6 +73,11 @@ object GlobalMediaPlayer { @Volatile private var audioPlayer: VideoPlayerState? = null + // In-flight `openUri` for the current video track. Cancelled before a new open so two rapid + // track switches (e.g. opening one live stream while another is still loading) can't interleave + // openUri calls on the single shared engine — that race left the player stuck / black. + @Volatile private var videoOpenJob: Job? = null + private val initLock = Any() /** @@ -120,12 +125,18 @@ object GlobalMediaPlayer { return } - if (current.url == url) { + // Reuse the engine only when it's already on this URL AND healthy. If the last attempt + // errored (a common transient for live HLS — a dead segment, a 403, a stream that just + // went live), fall through and re-open instead of leaving a stuck/errored surface. + if (current.url == url && current.errorReason == null) { + println("GlobalMediaPlayer.playVideo REUSE url=$url isPlaying=${current.isPlaying}") if (seekPosition > 0f) player.seekTo(seekPosition * 1000f) if (!current.isPlaying) player.play() return } + println("GlobalMediaPlayer.playVideo OPEN url=$url (was=${current.url} err=${current.errorReason})") + // Reset engine volume to match the UI's default for the new track. The // kdroidFilter player retains `volume` across openUri calls, so a mute // on the prior track would otherwise carry over while the UI shows the @@ -134,17 +145,20 @@ object GlobalMediaPlayer { _videoState.value = MediaPlaybackState(url = url, type = MediaType.VIDEO, isBuffering = true) - scope.launch(Dispatchers.IO) { - player.openUri(url) - // openUri auto-plays per InitialPlayerState.PLAY default. For an - // initial seek we wait for the first hasMedia=true emission then - // stop collecting (Flow.first terminates the collector cleanly, - // unlike `return@collect` which only exits the lambda). - if (seekPosition > 0f) { - snapshotFlow { player.hasMedia }.first { it } - player.seekTo(seekPosition * 1000f) + // Cancel any in-flight open so a rapid switch can't interleave openUri on the shared engine. + videoOpenJob?.cancel() + videoOpenJob = + scope.launch(Dispatchers.IO) { + player.openUri(url) + // openUri auto-plays per InitialPlayerState.PLAY default. For an + // initial seek we wait for the first hasMedia=true emission then + // stop collecting (Flow.first terminates the collector cleanly, + // unlike `return@collect` which only exits the lambda). + if (seekPosition > 0f) { + snapshotFlow { player.hasMedia }.first { it } + player.seekTo(seekPosition * 1000f) + } } - } } fun playAudio(url: String) { @@ -236,6 +250,7 @@ object GlobalMediaPlayer { } fun stopVideo() { + videoOpenJob?.cancel() videoPlayer?.stop() _videoState.value = MediaPlaybackState() _isFullscreen.value = false diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt index 50ded7df46..23a0006137 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt @@ -73,6 +73,44 @@ fun createFollowingFeedSubscription( ) } +/** + * Creates a subscription config for NIP-53 live streams (kind 30311). Pass [authors] to scope to a + * set of hosts (e.g. the user's follows); null fetches globally for discovery. + */ +fun createLiveActivitiesSubscription( + relays: Set, + authors: List? = null, + limit: Int = 100, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig = + SubscriptionConfig( + subId = generateSubId("live-activities"), + filters = listOf(FilterBuilders.liveActivities(authors = authors, limit = limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) + +/** + * Creates a subscription config for the live chat (kind 1311) of a single stream, keyed by the + * stream's 30311 address (`kind:pubkey:d`). + */ +fun createLiveChatSubscription( + relays: Set, + streamAddress: String, + limit: Int = 200, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig = + SubscriptionConfig( + subId = generateSubId("live-chat-${streamAddress.take(24)}"), + filters = listOf(FilterBuilders.liveActivityChat(streamAddress, limit = limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) + /** * Creates a subscription config for contact list (kind 3). */ diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index 9b2c043d53..525013809f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.desktop.subscriptions import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @@ -31,6 +33,28 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent object FilterBuilders { private val FEED_KINDS = listOf(1, 6, 16, 1068) // TextNoteEvent, RepostEvent, GenericRepostEvent, PollEvent + /** Filter for NIP-53 live streaming events (kind 30311), optionally scoped to specific hosts. */ + fun liveActivities( + authors: List? = null, + limit: Int = 100, + ): Filter = + Filter( + kinds = listOf(LiveActivitiesEvent.KIND), + authors = authors, + limit = limit, + ) + + /** Filter for NIP-53 live chat messages (kind 1311) tied to a stream by its root `a` tag. */ + fun liveActivityChat( + streamAddress: String, + limit: Int = 200, + ): Filter = + Filter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND), + tags = mapOf("a" to listOf(streamAddress)), + limit = limit, + ) + /** * Creates a filter for text notes (kind 1) from all authors. * diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt index 7fe5bfb77e..c4bd6ee386 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import java.util.concurrent.atomic.AtomicLong /** * Represents an active relay subscription that can be unsubscribed. @@ -122,4 +123,9 @@ fun rememberSubscription( /** * Generates a unique subscription ID with timestamp. */ -fun generateSubId(prefix: String): String = "$prefix-${System.currentTimeMillis()}" +private val subIdCounter = AtomicLong(0) + +// Append a monotonic per-process counter so two subscriptions created in the same millisecond +// (e.g. the live bar + the Discover Lives section on the same frame) can't collide on subId — a +// collision would make one's unsubscribe tear down the other's REQ. +fun generateSubId(prefix: String): String = "$prefix-${System.currentTimeMillis()}-${subIdCounter.incrementAndGet()}" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 3c77deba9c..a3e8cb262b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -137,6 +137,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscr import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.live.LiveNowBar import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.DesktopPollCard import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard @@ -580,6 +581,10 @@ fun FeedScreen( onZapFeedback: (ZapFeedback) -> Unit = {}, onNavigateToRelays: () -> Unit = {}, onSearchClick: () -> Unit = {}, + onOpenLive: (String) -> Unit = { + com.vitorpamplona.amethyst.desktop.ui.live.LiveWatchController + .open(it) + }, ) { val feedSearchActiveState = com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive.current var searchActive by feedSearchActiveState @@ -968,6 +973,20 @@ fun FeedScreen( // Reserve space for the header card that floats above the feed. Spacer(Modifier.height(headerSpacerHeight)) + // Pinned "live now" bar, scoped to this column's audience (Following / Global only). + if (feedMode == FeedMode.FOLLOWING || feedMode == FeedMode.GLOBAL) { + val liveBarPadding = LocalReadingSidePadding.current + Spacer(Modifier.height(8.dp)) + LiveNowBar( + cache = localCache, + relayManager = relayManager, + scopeAuthors = if (feedMode == FeedMode.FOLLOWING) followedUsers else null, + onOpenLive = onOpenLive, + modifier = Modifier.padding(horizontal = liveBarPadding + 12.dp), + ) + Spacer(Modifier.height(4.dp)) + } + // Feed content based on FeedState when (val state = feedState) { is FeedState.Loading -> { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LiveActivityRanking.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LiveActivityRanking.kt new file mode 100644 index 0000000000..db0f629e55 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LiveActivityRanking.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.live + +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivitySorting +import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivitySorting.LiveActivityRank +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Ranks + filters cached NIP-53 live-stream channels for the Desktop Discover grid and the + * per-column live bar, delegating the ordering contract to the shared, unit-tested + * [LiveActivitySorting] (so Desktop and Android order streams identically). + * + * [isOfflineNow] lets the caller feed in the online-probe verdict for a stream's `.m3u8` so a dead + * `status=live` sinks; pass `{ false }` until the probe is wired in. + */ +object LiveActivityRanking { + fun rankOf( + channel: LiveActivitiesChannel, + followSet: Set, + isOfflineNow: Boolean, + ): LiveActivityRank { + val info = channel.info + if (info == null) { + return LiveActivityRank(LiveActivitySorting.ORDER_ENDED, 0, 0, 0L, channel.address.toValue()) + } + val participants = info.participants() + val involved = + buildSet { + add(info.pubKey) + participants.forEach { add(it.pubKey) } + } + val followParticipants = involved.count { it in followSet } + val startOrCreated = info.starts() ?: info.createdAt + return LiveActivityRank( + statusOrder = LiveActivitySorting.statusOrder(info.status(), isOfflineNow), + followParticipants = followParticipants, + totalParticipants = participants.size, + startOrCreated = startOrCreated, + idHex = channel.address.toValue(), + ) + } + + /** Best-first ranked list for the Discover grid (live > planned > ended). */ + fun rankForDiscover( + channels: Collection, + followSet: Set, + isOfflineNow: (LiveActivitiesChannel) -> Boolean = { false }, + ): List = LiveActivitySorting.sortDescending(channels) { rankOf(it, followSet, isOfflineNow(it)) } + + /** + * The set of channels currently live-and-fresh within [followSet] (or globally when [followSet] + * is null), for the per-column "live now" bar. Ranked by viewer count (`current_participants`) + * per the design decision, newest first as a tiebreak. + */ + fun liveNowForBar( + channels: Collection, + followSet: Set?, + now: Long = TimeUtils.now(), + isOfflineNow: (LiveActivitiesChannel) -> Boolean = { false }, + ): List { + val eligible = + channels.filter { channel -> + val info = channel.info ?: return@filter false + val fresh = LiveActivitySorting.isLiveAndFresh(info.status(), info.createdAt, now, isOfflineNow(channel)) + fresh && (followSet == null || involvesFollow(channel, followSet)) + } + // Rank by viewers (current_participants), then recency. MUST go through sortDescending so the + // keys are snapshotted once — channel.info is a var swapped from relay threads, and reading it + // lazily inside a comparator violates TimSort's contract and crashes the feed column. + return LiveActivitySorting.sortDescending(eligible) { channel -> + val info = channel.info + LiveActivityRank( + statusOrder = LiveActivitySorting.ORDER_LIVE, + followParticipants = 0, + totalParticipants = info?.currentParticipants() ?: 0, + startOrCreated = info?.createdAt ?: 0L, + idHex = channel.address.toValue(), + ) + } + } + + private fun involvesFollow( + channel: LiveActivitiesChannel, + followSet: Set, + ): Boolean { + val info = channel.info ?: return false + if (info.pubKey in followSet) return true + return info.participants().any { it.pubKey in followSet } + } + + /** Client-side search over cached streams: title, host name, and hashtags. */ + fun matchesQuery( + channel: LiveActivitiesChannel, + query: String, + ): Boolean { + if (query.isBlank()) return true + val q = query.trim() + val info = channel.info ?: return false + if (info.title()?.contains(q, ignoreCase = true) == true) return true + if (channel.creatorName()?.contains(q, ignoreCase = true) == true) return true + if (info.summary()?.contains(q, ignoreCase = true) == true) return true + return info.tags.any { tag -> HashtagTag.parse(tag)?.contains(q, ignoreCase = true) == true } + } + + fun isLive(channel: LiveActivitiesChannel): Boolean = channel.info?.status() == StatusTag.STATUS.LIVE + + fun isPlanned(channel: LiveActivitiesChannel): Boolean = channel.info?.status() == StatusTag.STATUS.PLANNED + + /** + * True only when the stream is genuinely live right now: `status=live` AND its 30311 is fresh + * (re-published within the freshness window). Used to keep the "LIVE NOW" surfaces free of + * ended/planned/zombie streams. + */ + fun isLiveNow( + channel: LiveActivitiesChannel, + now: Long = TimeUtils.now(), + isOfflineNow: Boolean = false, + ): Boolean { + val info = channel.info ?: return false + return LiveActivitySorting.isLiveAndFresh(info.status(), info.createdAt, now, isOfflineNow) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LiveNowBar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LiveNowBar.kt new file mode 100644 index 0000000000..c210193092 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LiveNowBar.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.live + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.createLiveActivitiesSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription + +/** + * Compact pinned "live now" bar for the top of a feed column. Shows the single most-watched live + * host in the column's audience ([scopeAuthors] = the column's follows, or null for the global + * feed) plus a "+N live ›" affordance that expands the rest. Hidden entirely when nobody in scope + * is live. Clicking a host opens the watch screen via [onOpenLive]. + */ +@Composable +fun LiveNowBar( + cache: DesktopLocalCache, + relayManager: RelayConnectionManager, + scopeAuthors: Set?, + onOpenLive: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val connectedRelays by relayManager.connectedRelays.collectAsState() + + // Keyed on the Set (stable identity from the account state holder) — not a fresh list per + // recomposition — so the subscription and the snapshot below don't churn on unrelated recomposes. + rememberSubscription(connectedRelays, scopeAuthors, relayManager = relayManager) { + if (connectedRelays.isEmpty()) { + null + } else { + createLiveActivitiesSubscription( + relays = connectedRelays, + authors = scopeAuthors?.toList(), + onEvent = { event, _, relay, _ -> cache.consume(event, relay) }, + ) + } + } + + val version by cache.liveActivityVersion.collectAsState() + val liveNow = + remember(version, scopeAuthors) { + LiveActivityRanking.liveNowForBar(cache.snapshotLiveActivities(), scopeAuthors) + } + + if (liveNow.isEmpty()) return + + val top = liveNow.first() + var expanded by remember { mutableStateOf(false) } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { onOpenLive(top.address.toValue()) } + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + LiveDotSmall() + Spacer(Modifier.width(8.dp)) + Text( + text = topLabel(top.toBestDisplayName()), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (liveNow.size > 1) { + Spacer(Modifier.width(8.dp)) + Box { + Text( + text = "+${liveNow.size - 1} live ›", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.clickable { expanded = true }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + liveNow.forEach { channel -> + DropdownMenuItem( + text = { Text(channel.toBestDisplayName(), maxLines = 1, overflow = TextOverflow.Ellipsis) }, + onClick = { + expanded = false + onOpenLive(channel.address.toValue()) + }, + ) + } + } + } + } + } +} + +private fun topLabel(name: String): String = "$name is live" + +@Composable +private fun LiveDotSmall() { + Box( + modifier = + Modifier + .size(9.dp) + .clip(CircleShape) + .background(Color(0xFFD32F2F)), + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LiveWatchController.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LiveWatchController.kt new file mode 100644 index 0000000000..8a25507828 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LiveWatchController.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.live + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * App-level controller for the full-window live watch screen. Holds the address (`kind:pubkey:d`) + * of the stream currently being watched, or null when the watch overlay is closed. + * + * This is a singleton (like [com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer]) + * so any live surface — the Discover grid card, the per-column live bar, a profile Streams tab — + * can open the watch overlay via [open] without threading a callback up through the whole deck / + * single-pane navigation tree. The overlay itself is rendered once at the composition root in + * `Main.kt`, observing [current]. + */ +object LiveWatchController { + private val _current = MutableStateFlow(null) + val current: StateFlow = _current.asStateFlow() + + fun open(streamAddress: String) { + _current.value = streamAddress + } + + fun close() { + _current.value = null + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LiveWatchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LiveWatchScreen.kt new file mode 100644 index 0000000000..616dda9c93 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LiveWatchScreen.kt @@ -0,0 +1,373 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.live + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer +import com.vitorpamplona.amethyst.desktop.subscriptions.createLiveChatSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * Full-window watch screen for a NIP-53 live stream: HLS player on the left, live chat (kind 1311) + * on the right, host/metadata + status below the player. Rendered as an app-root overlay driven by + * [LiveWatchController]; [onClose] tears it down and returns to the deck / single-pane view. + * + * v1 scope: watch + read + post chat. Zap-the-stream and the live-vs-VOD player refinements + * (seek-bar suppression, stall watchdog) are follow-ups. + */ +@Composable +fun LiveWatchScreen( + address: String, + cache: DesktopLocalCache, + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn?, + onClose: () -> Unit, +) { + val channel = + remember(address) { + Address.parse(address)?.let { cache.getOrCreateLiveActivityChannel(it) } + } + + LaunchedEffect(address, channel?.info) { + val info = channel?.info + println( + "LiveWatchScreen open address=$address status=${info?.status()} " + + "streaming=${info?.streaming() ?: ""} recording=${info?.recording() ?: ""}", + ) + } + + // Stop playback when the watch overlay closes (leaves composition) so audio/decoding doesn't + // keep running in the background. Switching to another stream keeps the overlay composed (only + // `address` changes), so this fires only on a real close. + DisposableEffect(Unit) { + onDispose { GlobalMediaPlayer.stopVideo() } + } + + Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) { + if (channel == null) { + WatchError("This stream could not be opened.", onClose) + return@Surface + } + + val connectedRelays by relayManager.connectedRelays.collectAsState() + + // Live chat subscription for this stream (kind 1311 by the root `a` tag), torn down on close. + rememberSubscription(connectedRelays, address, relayManager = relayManager) { + if (connectedRelays.isEmpty()) { + null + } else { + createLiveChatSubscription( + relays = connectedRelays, + streamAddress = address, + onEvent = { event, _, relay, _ -> cache.consume(event, relay) }, + ) + } + } + + Column(modifier = Modifier.fillMaxSize()) { + // Top bar + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 16.dp, bottom = 8.dp), + ) { + IconButton(onClick = onClose) { + SymbolIcon(symbol = MaterialSymbols.Close, contentDescription = "Close") + } + Spacer(Modifier.width(4.dp)) + Text( + text = channel.toBestDisplayName(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + HorizontalDivider() + + Row(modifier = Modifier.fillMaxSize()) { + // Left: player + metadata + Column(modifier = Modifier.weight(1f).fillMaxSize().padding(16.dp)) { + val streamUrl = channel.info?.streaming() + if (streamUrl != null) { + DesktopVideoPlayer( + url = streamUrl, + autoPlay = true, + isLive = channel.info?.status() == StatusTag.STATUS.LIVE, + modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(RoundedCornerShape(8.dp)), + ) + } else { + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Text("Stream is offline", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + Spacer(Modifier.height(12.dp)) + WatchHeader(channel = channel) + } + + VerticalDivider() + + // Right: live chat + Column(modifier = Modifier.width(380.dp).fillMaxSize()) { + Text( + text = "LIVE CHAT", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp), + ) + HorizontalDivider() + ChatColumn( + channel = channel, + account = account, + relayManager = relayManager, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) + } + } + } + } +} + +@Composable +private fun WatchHeader(channel: com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel) { + val info = channel.info + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + if (info?.status() == StatusTag.STATUS.LIVE) { + Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFFD32F2F))) + Spacer(Modifier.width(6.dp)) + Text("LIVE", style = MaterialTheme.typography.labelMedium, color = Color(0xFFD32F2F), fontWeight = FontWeight.Bold) + Spacer(Modifier.width(12.dp)) + } + val host = channel.creatorName() + if (host != null) { + Text( + text = host, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(Modifier.weight(1f)) + val viewers = info?.currentParticipants() + if (viewers != null) { + Text("$viewers watching", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + val summary = channel.summary() + if (!summary.isNullOrBlank()) { + Spacer(Modifier.height(6.dp)) + Text(summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +private fun ChatColumn( + channel: com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel, + account: AccountState.LoggedIn?, + relayManager: DesktopRelayConnectionManager, + modifier: Modifier = Modifier, +) { + val notesState by channel + .flow() + .notes.stateFlow + .collectAsState() + // Newest first so reverseLayout puts the latest message at the visual bottom. + val messages = + remember(notesState) { + channel.notes + .values() + .filter { it.event is LiveActivitiesChatMessageEvent } + .sortedByDescending { it.createdAt() ?: 0L } + } + + val listState = rememberLazyListState() + // Auto-stick to the newest message while the user is at the bottom (index 0 in reverseLayout). + // Keyed on the newest message id (not size) so it still fires once the 500-cap prune keeps size + // flat (drop-one/add-one) on a busy stream. + LaunchedEffect(messages.firstOrNull()?.idHex) { + if (messages.isNotEmpty() && listState.firstVisibleItemIndex <= 1) { + listState.animateScrollToItem(0) + } + } + + Column(modifier = modifier) { + LazyColumn( + state = listState, + reverseLayout = true, + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + ) { + items(messages, key = { it.idHex }) { note -> + ChatMessageRow( + author = note.author?.toBestDisplayName() ?: "anon", + content = note.event?.content.orEmpty(), + ) + } + } + HorizontalDivider() + ChatComposer(channel = channel, account = account, relayManager = relayManager) + } +} + +@Composable +private fun ChatMessageRow( + author: String, + content: String, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = author, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text(text = content, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun ChatComposer( + channel: com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel, + account: AccountState.LoggedIn?, + relayManager: DesktopRelayConnectionManager, +) { + val scope = rememberCoroutineScope() + var text by remember { mutableStateOf("") } + var sending by remember { mutableStateOf(false) } + + val canPost = account != null && !account.isReadOnly + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(8.dp), + ) { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + enabled = canPost && !sending, + singleLine = true, + placeholder = { Text(if (canPost) "Say something…" else "Log in to chat") }, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + TextButton( + enabled = canPost && !sending && text.isNotBlank(), + onClick = { + val acct = account ?: return@TextButton + val body = text.trim() + if (body.isBlank()) return@TextButton + sending = true + scope.launch { + try { + withContext(Dispatchers.IO) { + val template = LiveActivitiesChatMessageEvent.message(body, channel.toATag()) + val signed = acct.signer.sign(template) + relayManager.publish(signed, relayManager.connectedRelays.value) + } + text = "" + } finally { + sending = false + } + } + }, + ) { + Text("Send") + } + } +} + +@Composable +private fun WatchError( + message: String, + onClose: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxSize(), + ) { + Text(message, style = MaterialTheme.typography.bodyLarge) + Spacer(Modifier.height(12.dp)) + TextButton(onClick = onClose) { Text("Close") } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LivesSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LivesSection.kt new file mode 100644 index 0000000000..2490177cfe --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/live/LivesSection.kt @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.live + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.FlowRowOverflow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.createLiveActivitiesSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * "LIVE NOW" section for the Desktop Discover screen: subscribes to NIP-53 live streams (kind + * 30311) while visible, ranks them via the shared [LiveActivityRanking] (live > planned > ended, + * follow-participation, then viewers), and offers a client-side search over title / host / hashtag. + * Clicking a card invokes [onOpenLive] with the stream's address (`kind:pubkey:d`). + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun LivesSection( + cache: DesktopLocalCache, + relayManager: RelayConnectionManager, + followSet: Set, + onOpenLive: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val connectedRelays by relayManager.connectedRelays.collectAsState() + + // Subscribe to global live streams while this section is composed; torn down on dispose. + rememberSubscription(connectedRelays, relayManager = relayManager) { + if (connectedRelays.isEmpty()) { + null + } else { + createLiveActivitiesSubscription( + relays = connectedRelays, + authors = null, + onEvent = { event, _, relay, _ -> cache.consume(event, relay) }, + ) + } + } + + val version by cache.liveActivityVersion.collectAsState() + var query by rememberSaveable { mutableStateOf("") } + + // "LIVE NOW" shows only streams that are genuinely live right now — never planned or ended. + val ranked = + remember(version, query, followSet) { + val liveOnly = cache.snapshotLiveActivities().filter { LiveActivityRanking.isLiveNow(it) } + LiveActivityRanking + .rankForDiscover(liveOnly, followSet) + .filter { LiveActivityRanking.matchesQuery(it, query) } + } + + // Hide the whole section when nothing is live (keeps Discover clean; no empty band). + if (ranked.isEmpty() && query.isBlank()) return + + Column(modifier = modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + LiveDot() + Spacer(Modifier.width(6.dp)) + Text( + text = "LIVE NOW", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = query, + onValueChange = { query = it }, + singleLine = true, + placeholder = { Text("Search live streams…") }, + keyboardOptions = KeyboardOptions.Default, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + if (ranked.isEmpty()) { + Text( + text = "No live streams match \"$query\".", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + // Cap at two rows so the rest of Discover ("From the pack" etc.) stays visible; extra + // live streams are still reachable via the search box and the per-column live bar. + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + maxLines = 2, + overflow = FlowRowOverflow.Clip, + modifier = Modifier.fillMaxWidth(), + ) { + ranked.forEach { channel -> + LiveStreamCard( + channel = channel, + onClick = { onOpenLive(channel.address.toValue()) }, + ) + } + } + } + } +} + +@Composable +private fun LiveStreamCard( + channel: LiveActivitiesChannel, + onClick: () -> Unit, +) { + val info = channel.info + Card( + modifier = + Modifier + .width(240.dp) + .clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(12.dp), + ) { + Box { + val image = channel.profilePicture() + if (image != null) { + AsyncImage( + model = image, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), + ) + } else { + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .background(MaterialTheme.colorScheme.surface), + ) + } + StatusBadge( + channel = channel, + modifier = Modifier.align(Alignment.TopStart).padding(6.dp), + ) + } + Column(modifier = Modifier.padding(10.dp)) { + Text( + text = channel.toBestDisplayName(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val host = channel.creatorName() + if (host != null) { + Spacer(Modifier.height(2.dp)) + Text( + text = host, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + val viewers = info?.currentParticipants() + if (viewers != null && LiveActivityRanking.isLive(channel)) { + Spacer(Modifier.height(2.dp)) + Text( + text = "$viewers watching", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun StatusBadge( + channel: LiveActivitiesChannel, + modifier: Modifier = Modifier, +) { + val status = channel.info?.status() + when (status) { + StatusTag.STATUS.LIVE -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xE6D32F2F)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text("LIVE", style = MaterialTheme.typography.labelSmall, color = Color.White, fontWeight = FontWeight.Bold) + } + + StatusTag.STATUS.PLANNED -> + Text( + text = startsInLabel(channel.info?.starts()), + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = + modifier + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xCC000000)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + + else -> Unit + } +} + +@Composable +private fun LiveDot() { + Box( + modifier = + Modifier + .size(10.dp) + .clip(CircleShape) + .background(Color(0xFFD32F2F)), + ) +} + +private fun startsInLabel(startsAt: Long?): String { + if (startsAt == null) return "SCHEDULED" + val delta = startsAt - TimeUtils.now() + if (delta <= 0) return "SCHEDULED" + val hours = delta / 3600 + val minutes = (delta % 3600) / 60 + return when { + hours >= 24 -> "in ${hours / 24}d" + hours >= 1 -> "in ${hours}h" + else -> "in ${minutes}m" + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt index 1d809b8056..6f10065621 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt @@ -63,6 +63,7 @@ fun DesktopVideoPlayer( viewMode: ViewMode = ViewMode.DEFAULT, onViewModeChange: ((ViewMode) -> Unit)? = null, trailingControls: @Composable (() -> Unit)? = null, + isLive: Boolean = false, ) { val videoState by GlobalMediaPlayer.videoState.collectAsState() val isActiveVideo = videoState.url == url @@ -89,6 +90,14 @@ fun DesktopVideoPlayer( } } + // Surface playback errors to the log — kdroidFilter reports these only via state, so without + // this a stream that fails to start ("live doesn't start") leaves no trace. + LaunchedEffect(isActiveVideo, videoState.errorReason) { + if (isActiveVideo && videoState.errorReason != null) { + println("DesktopVideoPlayer ERROR url=$url reason=${videoState.errorReason}") + } + } + if (isActiveVideo && videoState.aspectRatio != 16f / 9f) { aspectRatio = videoState.aspectRatio } @@ -167,6 +176,7 @@ fun DesktopVideoPlayer( }, onViewModeChange = onViewModeChange, trailingControls = trailingControls, + isLive = isLive, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt index 4cd26ddd39..f31327ba76 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -72,6 +73,7 @@ fun VideoControls( viewMode: ViewMode = ViewMode.DEFAULT, onViewModeChange: ((ViewMode) -> Unit)? = null, trailingControls: @Composable (() -> Unit)? = null, + isLive: Boolean = false, ) { var hovering by remember { mutableStateOf(false) } @@ -128,18 +130,21 @@ fun VideoControls( .background(Color.Black.copy(alpha = 0.6f)) .padding(horizontal = 8.dp), ) { - // Seek slider (full width, no horizontal competition) - Slider( - value = position, - onValueChange = onSeek, - modifier = Modifier.fillMaxWidth(), - colors = - SliderDefaults.colors( - thumbColor = Color.White, - activeTrackColor = MaterialTheme.colorScheme.primary, - inactiveTrackColor = Color.White.copy(alpha = 0.3f), - ), - ) + // Seek slider (full width, no horizontal competition). Hidden for live streams — + // the underlying HLS is non-seekable, so a scrubber would be inert and misleading. + if (!isLive) { + Slider( + value = position, + onValueChange = onSeek, + modifier = Modifier.fillMaxWidth(), + colors = + SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = Color.White.copy(alpha = 0.3f), + ), + ) + } // Buttons row Row( @@ -159,11 +164,32 @@ fun VideoControls( ) } - Text( - text = "${formatTime(currentTime)} / ${formatTime(duration)}", - style = MaterialTheme.typography.labelSmall, - color = Color.White, - ) + if (isLive) { + // Live: a single "elapsed since you started watching" timer + a LIVE dot — + // no total duration (the stream has no fixed end). + Box( + modifier = + Modifier + .size(8.dp) + .background(Color(0xFFD32F2F), shape = CircleShape), + ) + Text( + text = "LIVE", + style = MaterialTheme.typography.labelSmall, + color = Color(0xFFFF5252), + ) + Text( + text = formatTime(currentTime), + style = MaterialTheme.typography.labelSmall, + color = Color.White, + ) + } else { + Text( + text = "${formatTime(currentTime)} / ${formatTime(duration)}", + style = MaterialTheme.typography.labelSmall, + color = Color.White, + ) + } // Spacer pushes right-side controls to the end Box(Modifier.weight(1f)) 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