Commit Graph

12501 Commits

Author SHA1 Message Date
Claude bc43168032 chore(audio-rooms): post-moq-lite cleanup + KDoc + plan refresh
Tidy items now that the moq-lite swap is complete on both sides:

  - Drop the no-op `supportedMoqVersions: List<Long>` parameter from
    `connectNestsListener` / `connectNestsSpeaker`. moq-lite negotiates
    via ALPN, no caller passed a value, and the project rule forbids
    no-op back-compat shims.
  - `NostrNestsRoundTripInteropTest` KDoc + comments now describe the
    moq-lite framing path (one Subscribe bidi for `audio/data`, group
    uni streams with `DataType=0` + GroupHeader + size-prefixed frames)
    instead of the stale IETF "OBJECT_DATAGRAMs / SETUP" framing.
  - `DefaultNestsListener` / `DefaultNestsSpeaker` KDoc now flags them
    as IETF MoQ-transport reference impls — production uses
    `MoqLiteNests*`. They stay around for the IETF unit-test suite.
  - `audio-rooms-completion.md` Phase M5 / M6 / M7 marked **DONE** —
    the plan was written before the moq-lite gap was discovered, so
    it described the work as IETF-MoQ-publisher additions; the
    moq-lite path lands the same outcome via a different protocol.
2026-04-26 18:18:33 +00:00
Claude 71cf99dc22 feat(audio-rooms): moq-lite speaker side end-to-end (phase 5c-speaker)
Production speaker path now runs on moq-lite, so connectNestsSpeaker
exchanges real moq-lite framing with the nostrnests reference relay.

Transport layer:
  - WebTransportSession.incomingBidiStreams() — peer-initiated bidi
    flow. moq-lite publishers receive Announce + Subscribe bidis from
    the relay (rs/moq-lite/src/lite/publisher.rs:40 uses
    Stream::accept(session)), so the abstraction grew the
    accept-bidi-from-peer surface.
  - WebTransportSession.openUniStream() — locally-opened uni stream
    for group push (rs/moq-lite/src/lite/publisher.rs:338 uses
    session.open_uni()).
  - :quic WtPeerStreamDemux StrippedWtStream now carries optional
    send/finish closures. The demux takes the QuicConnectionDriver
    so wakeups fire after each app-level write on a peer-initiated
    bidi.
  - FakeWebTransport now exposes incomingBidiStreams + openUniStream
    directly; the openPeerUniStream test helper went away (production
    flow covers it).

Session layer:
  - MoqLiteSession.publish(suffix) — claims a broadcast suffix and
    lazily launches a relay→us bidi pump. ControlType=Announce reads
    AnnouncePlease, replies Active(suffix). ControlType=Subscribe reads
    body, replies SubscribeOk, registers the inbound subscription.
  - MoqLitePublisherHandle — startGroup / send / endGroup / close
    semantics. send opens a uni stream per group with DataType=0 +
    GroupHeader and pushes varint(size)+payload frames. close emits
    Announce(Ended) on every active announce bidi, FINs the uni.

Application layer:
  - AudioRoomMoqLiteBroadcaster — sibling of AudioRoomBroadcaster but
    drives MoqLitePublisherHandle (keeps IETF broadcaster intact for
    its unit tests).
  - MoqLiteNestsSpeaker — NestsSpeaker adapter, mirror of
    MoqLiteNestsListener on the publish side.
  - connectNestsSpeaker now opens a MoqLiteSession (no SETUP) and
    returns MoqLiteNestsSpeaker.

Tests:
  - 4 new MoqLiteSessionTest cases:
    publisher_replies_to_announcePlease_with_active_announce,
    publisher_acks_subscribe_and_pushes_group_data_on_uni_stream,
    publisher_send_returns_false_when_no_inbound_subscriber,
    publisher_close_emits_ended_announce.

Verified :commons:compileKotlinJvm + :amethyst:compilePlayDebugKotlin
both still compile against the swap.

Docs (plans + CLAUDE.md) refreshed to reflect speaker-side landing.
2026-04-26 18:01:00 +00:00
Vitor Pamplona 49f769f92a Merge pull request #2590 from vitorpamplona/claude/fix-fdroid-lint-errors-i6aEY
fix(playback): hoist DataSourceBitmapLoader build into a function
2026-04-26 13:59:51 -04:00
Vitor Pamplona 6540b81512 Merge pull request #2589 from vitorpamplona/claude/audit-remember-functions-6lpHA
perf(ui): drop remember wrappers where overhead exceeds savings
2026-04-26 13:41:33 -04:00
Claude 5914e9e9fc docs(audio-rooms): moq-lite listener landed — refresh status callouts
Phase 5d wrapped, so the doc set now reflects "listener path done,
speaker pending":

  - nestsClient/plans/2026-04-26-moq-lite-gap.md — new "Implementation
    status" section maps phases 5a → 5d to commits, calls out the
    speaker side as blocked on a small `WebTransportSession.acceptBidi`
    extension (since publisher.rs:40 uses Stream::accept), and points
    at the existing :quic primitive (QuicConnection.awaitIncomingPeerStream)
    that the bridge can lean on.
  - nestsClient/plans/2026-04-26-audio-rooms-completion.md — Phase M1
    is no longer "on hold for moq-lite"; manual nostrnests.com
    validation should now work end-to-end on the listener path.
  - .claude/CLAUDE.md — :nestsClient now hosts both IETF MoQ-transport
    and moq-lite Lite-03; production listener path uses moq-lite.
2026-04-26 17:39:23 +00:00
Claude 41f4dcd9ac feat(audio-rooms): connectNestsListener uses moq-lite (phase 5d)
Production wiring switch — `connectNestsListener` now opens a
moq-lite (Lite-03) session against the WebTransport peer instead of
running the IETF MoQ-transport SETUP handshake. The downstream
NestsListener interface is unchanged; consumer code in commons /
amethyst keeps working.

Listener flow (per the audio-rooms NIP draft + nostrnests JS reference):
  - subscribeSpeaker(pubkey) → MoqLiteSession.subscribe(broadcast =
    pubkey, track = "audio/data")
  - frames map to MoqObject so AudioRoomPlayer / AudioRoomViewModel
    keep working unchanged. Per-frame `objectId` is synthesised as a
    monotonic counter (moq-lite has no per-frame ID); `groupId` =
    moq-lite group sequence; `trackAlias` = subscribe id.

NestsListenerState.Connected.negotiatedMoqVersion now reports
MOQ_LITE_03_VERSION (a synthetic constant carrying the ALPN suffix
in the low bytes) since moq-lite has no in-band SETUP message.

NestsConnectTest:
  - Drop the SETUP-faking peer side — moq-lite has no SETUP, so
    connectNestsListener returns Connected immediately after the WT
    handshake.
  - Assert MOQ_LITE_03_VERSION on Connected.

Speaker path (`connectNestsSpeaker`) is untouched — it still uses
the IETF DefaultNestsSpeaker. Speaker-side moq-lite needs
`acceptBidiStream` on the WebTransportSession interface so the relay
can open Announce/Subscribe bidis to the publisher; tracked in
plans/2026-04-26-moq-lite-gap.md phase-5c-speaker.

Verified `:commons:compileKotlinJvm` + `:amethyst:compilePlayDebugKotlin`
both still compile against the swap.
2026-04-26 17:29:51 +00:00
davotoula f96b42f6b9 fix(lint): add @file:OptIn(UnstableApi::class) to MediaSessionPool
Property-level @OptIn doesn't propagate through the lazy{} delegate body,
so lint flags the DataSourceBitmapLoader.Builder chain (lines 87-90) with
UnsafeOptInUsageError. File-level annotation is a one-line fix that lets
:amethyst:lintPlayDebug pass without changing runtime semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:28:21 +02:00
Claude 4e136cacf4 feat(audio-rooms): MoqLiteSession listener-side (phase 5c-listener)
Listener-side moq-lite (Lite-03) session that wraps a
WebTransportSession and exposes:

  - announce(prefix) — opens an Announce bidi with
    ControlType=Announce + AnnouncePlease(prefix), surfaces a flow
    of relay Announce updates (Active/Ended)
  - subscribe(broadcast, track, ...) — opens a Subscribe bidi with
    ControlType=Subscribe + body, reads the size-prefixed response,
    returns a MoqLiteSubscribeHandle whose frames flow yields each
    frame the publisher pushes
  - close() — cancels pumps, FINs every active subscribe bidi
    (moq-lite UNSUBSCRIBE is "FIN the bidi"), closes the transport

Group demux: a single inbound-uni-stream pump per session reads the
DataType byte (Group=0) + size-prefixed group header, then routes
each subsequent (size, payload) frame to the matching
subscriptionsBySubscribeId entry's frame channel.

Speaker side stops at the announce/subscribe interface — moq-lite
publisher flows depend on accept_bi() / server-initiated bidi
streams (see clarifying agent summary in plans/2026-04-26-moq-lite-gap.md).
That's a follow-up; the WebTransportSession interface needs an
acceptBidiStream() method first.

FakeWebTransport.openPeerUniStream() — new helper so tests can
inject group data from the peer side without going through the
session's outbound uni stream API.

Tests in MoqLiteSessionTest — runBlocking-based (not runTest) so
real-time channel coordination works without virtual-time
artifacts:
  - subscribe_writes_request_and_returns_handle_on_ok
  - subscribe_throws_on_drop_response
  - announce_streams_relay_updates
  - groups_are_demuxed_by_subscribeId (no cross-talk)
  - unsubscribe_FINs_the_subscribe_bidi
2026-04-26 17:24:17 +00:00
Claude 6f04edd995 perf(ui): drop remember wrappers where overhead exceeds savings
Cases removed:
- Trivial Modifier allocations (Modifier.weight/padding/size) — slot table
  cost dominates the cost of building a fresh Modifier each recomposition.
- Map.keys views over relayStatuses on desktop screens — .keys is a
  property read on the same map, no need to memoize.
- coerceIn() arithmetic on AudioWaveform Dp/Float params — two compares
  are cheaper than the slot table read+compare.
- fadeIn()/fadeOut() in AnimatedVisibility — small EnterTransition
  allocations that don't justify the slot table overhead.

Audit-only changes; no behavior changes.

https://claude.ai/code/session_011Ea2pVjwvCEx7X4izwryV4
2026-04-26 17:02:53 +00:00
Claude 179642d78b fix(playback): hoist DataSourceBitmapLoader build into a function
Android Lint's UnsafeOptInUsageError doesn't recognize @OptIn placed on a
`by lazy` property as covering the lambda body, so each Media3 unstable-API
call inside the initializer (Builder, setExecutorService, setDataSourceFactory,
build) was flagged. Move the construction into a real function carrying the
@OptIn annotation; the lazy delegate just calls it. No behavior change.
2026-04-26 16:54:26 +00:00
Claude fb47a4cf75 feat(audio-rooms): moq-lite codec primitives + message types (phase 5a/5b)
First two phases of the moq-lite (Lite-03) implementation per
nestsClient/plans/2026-04-26-moq-lite-gap.md. No production wiring
yet — the production helpers still call the IETF MoqSession.

What landed:
  - MoqLitePath — mandatory wire-boundary normalisation (strip
    leading/trailing/duplicate `/`), join, path-component-aware
    stripPrefix. Mirrors rs/moq-lite/src/path.rs semantics.
  - MoqLiteAlpn / MoqLiteControlType / MoqLiteDataType /
    MoqLiteAnnounceStatus / MoqLiteSubscribeResponseType — wire
    enums for ALPN ("moq-lite-03"), per-bidi ControlType varints,
    Group uni-stream type byte, announce status, subscribe
    response type.
  - Message data classes — AnnouncePlease, Announce, Subscribe,
    SubscribeOk, SubscribeDrop, GroupHeader, Probe.
  - MoqLiteCodec — encode/decode for each message, with the
    size-prefix envelope baked in. Handles the off-by-one
    `0 = None, n = Some(n−1)` trick for startGroup/endGroup,
    coerces booleans to 0/1, validates priority fits in u8,
    rejects unknown status/response bytes. Path normalisation
    applied at every encode + decode boundary.
  - MoqLitePathTest, MoqLiteCodecTest — round-trip tests for
    every codec entry point + negative paths (oversized priority,
    invalid ordered byte, unknown status, trailing garbage).

All MoqWriter / MoqReader / MoqCodecException primitives reused
from the IETF MoQ codec — same varint and length-prefix shapes.
2026-04-26 16:46:55 +00:00
Claude 7f48e52541 docs(audio-rooms): full moq-lite wire spec + IETF gap call-outs
Background research turned up the complete moq-lite (Lite-03) wire
format from kixelated/moq-rs and @moq/lite v0.1.7. Folded the spec
into nestsClient/plans/2026-04-26-moq-lite-gap.md as a phase-5
implementation plan:

  - ALPN ("moq-lite-03"); no SETUP/control message in Lite-03 (the WT
    handshake IS the handshake)
  - Per-request bidi streams keyed by ControlType varint (Announce=1,
    Subscribe=2, Fetch=3, Probe=4)
  - AnnouncePlease(prefix) / Announce(status, suffix, hops) shape
  - Subscribe with priority (raw u8), ordered, maxLatency (ms),
    startGroup/endGroup (off-by-one None-encoded), reply Ok/Drop
  - Group = uni stream with (DataType=0, subscribeId, sequence)
    header followed by varint-length frames until QUIC FIN
  - No datagrams; no per-frame envelope beyond size
  - Mandatory path normalisation; FIN-as-unsubscribe; RESET_STREAM
    for errors

Phase-5a..e implementation plan included (~1 week scope).

Doc + KDoc updates so the IETF MoQ-transport code is correctly
labelled and the moq-lite gap is discoverable from every entry point:

  - .claude/CLAUDE.md project description and architecture diagram
  - nestsClient/plans/2026-04-26-audio-rooms-completion.md status block
  - MoqSession.kt, MoqMessage.kt, MoqObject.kt, MoqCodec.kt KDoc — flag
    these as "IETF draft-ietf-moq-transport-17, NOT moq-lite", with
    pointers to the gap doc
  - NestsConnect.kt — note that step 3 of the listener handshake
    (SETUP) does NOT match nostrnests's relay framing
2026-04-26 16:39:44 +00:00
Vitor Pamplona d589fc4614 Merge pull request #2588 from vitorpamplona/claude/optimize-composables-performance-bpO05
perf(note types): cache event-derived values and hoist static modifiers
2026-04-26 12:38:17 -04:00
Claude 1887bd1fa7 test/refactor(audio-rooms): nostrnests wire-shape fixes + interop expansion (phase 4)
Wire-shape corrections discovered while scoping the interop test suite
against the real moq-rs relay:

  1. WebTransport CONNECT path is now /<moqNamespace> (matches the
     relay's claims.root prefix check). Previously hardcoded "/anon".
  2. JWT travels in the ?jwt=<token> query parameter, not the
     Authorization header — moq-rs only reads the query param. The
     bearer-token path on QuicWebTransportFactory is now unused for
     nests; left in place for non-nests WebTransport servers.
  3. Harness `moqEndpoint` is the relay base URL only; the connect
     helpers append /<namespace>?jwt=<token> themselves.

Interop test additions (all -DnestsInterop=true gated, default-skipped):

  - NostrNestsAuthFailureInteropTest — locks in the moq-auth sidecar's
    rejection paths (missing/wrong-scheme Authorization, NIP-98 signed
    for the wrong URL, malformed namespace per the strict regex,
    publish=true grant for any caller — sidecar does NOT gate by NIP-53
    hostlist).
  - NostrNestsAuthEndpointsInteropTest — /health, /.well-known/jwks.json
    shape (must contain ES256/P-256), 404 on unknown route.
  - NostrNestsMultiPeerInteropTest — multi-listener fan-out, multi-
    speaker isolation, subscribe-before-announce. Code is wired through
    production connectNestsSpeaker / connectNestsListener; will pass
    once the moq-lite gap (below) is resolved.

Major finding documented in nestsClient/plans/2026-04-26-moq-lite-gap.md:
nostrnests's stack uses moq-lite (kixelated's variant), NOT IETF
draft-ietf-moq-transport which `:nestsClient` currently implements. The
two are wire-incompatible — single-string broadcast/track names vs. byte
tuples, different ANNOUNCE/SUBSCRIBE framing. The wire-shape fixes here
make the WebTransport CONNECT itself succeed, but the post-CONNECT MoQ
framing layer still needs a moq-lite codec before round-trip / multi-peer
tests can pass against real nests. Pursued as a separate phase.
2026-04-26 15:53:02 +00:00
Claude 46f305fe1a perf(chats): typed sealed key instead of concatenated string
The previous fix used `"ch:${id}"`, `"dm:${users.sorted().joinToString}"`
etc., which allocates a StringBuilder + char[] + new String per call —
worst case for the DM branch which also allocates a sorted List on top.

Replace with a sealed `ChatroomLazyKey` and per-type data classes that
just wrap the existing String / RoomId / ChatroomKey. Equality and
hashCode are auto-generated, so Compose still moves rows correctly on
reorder, and we drop most of the per-key allocations:

  ch:abc          -> PublicChannelLazyKey(abc)        # 1 wrapper, reused String
  dm:userA,userB  -> PrivateChatLazyKey(chatroomKey)  # 1 wrapper, reused ChatroomKey
  eph:roomId      -> EphemeralChannelLazyKey(roomId)  # 1 wrapper, reused RoomId
2026-04-26 15:51:15 +00:00
Claude 06340dbdf9 fix(chats): stable per-chatroom LazyColumn key for messages list
The chatroom list keyed each row by `if (index == 0) index else item.idHex`.
Two problems:

1. Position 0 was hardcoded to key `0`, so when a new chatroom moved to
   the top, the existing composition slot was reused with state from the
   previous chatroom — observed as "the row updated and reordered but
   still shows the old result" right at the top.
2. For other positions, the key was the latest message's `idHex`. When a
   new message arrived in any chatroom the chatroom's representative
   Note got replaced (different idHex), so Compose threw away the row
   and rebuilt it from scratch — wasted work.

Fix: derive a stable key from chatroom identity instead of message id —
nostr group id for marmot rooms, channel id for public/ephemeral
channels, sorted user set for DMs. Falls back to `item.idHex` for
unrecognized event types (drafts etc.). Reorders now move the row;
new-message updates re-use the slot.
2026-04-26 15:44:28 +00:00
Claude 6aecfe016b perf(note types): second pass — fix more missing remember keys
Follow-up to the first perf pass. Same goal: cut allocation cost for
note rows that scroll inside LazyColumn feeds.

- AppDefinition: key the `remember { tags.toImmutableListOfLists() }`
  block by `note` so it actually invalidates when the note changes
- NIP90ContentDiscoveryResponse: drop the `remember(note) {
  Modifier.fillMaxWidth() }` wrapper — `Modifier.fillMaxWidth()` is a
  constant call
- PeopleList: key the `derivedStateOf` for `name` by `noteEvent`, and
  switch `LaunchedEffect(Unit)` to `LaunchedEffect(noteEvent)` so the
  participants reload when the underlying event changes
- PinList: replace `val pins by remember { mutableStateOf(noteEvent
  .pinnedEvents()) }` with `val pins = remember(noteEvent) { … }` —
  the `mutableStateOf` wrapper was unnecessary and the missing key
  meant `pins` could go stale on event updates
- LongForm: return the `topics` list as `ImmutableList` so Compose
  treats it as a stable parameter to the consuming `forEach`
- RelayList: drop the `mutableStateOf(RelayListCard(…))` wrap inside
  4 `remember` blocks (DisplayRelaySet, DisplayNIP65RelayList write/
  read, DisplayDMRelayList) — the value never changes after creation;
  also key by `noteEvent` rather than `baseNote`, and cache
  `noteEvent.description()`
- Torrent: wrap `noteEvent.title() + totalSizeBytes()`, content
  comparison and `files().toImmutableList()` in `remember(noteEvent)`
  so they don't recompute and reallocate on every recomposition
2026-04-26 15:34:22 +00:00
Claude bf540db557 perf(note types): cache event-derived values and hoist static modifiers
Reduce per-recomposition allocation cost for note rows that render inside
LazyColumn feeds:

- AudioTrack: add `noteEvent` keys to `remember` for media/cover/subject/
  participants/waveform/content so the cached values invalidate when the
  underlying event changes
- Classifieds: wrap `imageMetas().map { MediaUrlImage(...) }`, title,
  summary, price and location in `remember(noteEvent)` so they aren't
  recomputed on every recomposition; hoist the static price-tag modifier
  to a top-level `val`
- Report: collapse the per-recomposition `map { stringRes(...) }` chain
  into a single `remember(reportTypes, noteEvent)` over a deduplicated
  set of report types, and key the `base` collection by `noteEvent`
- Highlight: key the URL-parse `remember` by `url` so it actually
  re-validates when the parameter changes
- PrivateMessage: key `remember { noteEvent.with(...) }` by `noteEvent`,
  drop the silly `remember { Modifier.fillMaxWidth() }` wrapper, and
  key `isLoggedUser` by `note.author` instead of `note.event?.id`
- PictureDisplay / FileHeader / Video: drop the unnecessary
  `mutableStateOf(...)` wrap inside `remember` blocks that produce
  immutable `BaseMediaContent` values; cache `images.map { it.url }`
  preload list, and key `title`/`summary`/`image`/`isYouTube` by event
- Poll: add the missing `it.label` and `card` keys to `remember` blocks
  that derive booleans from those parameters
- MeetingSpace: hoist the three `MeetingSpace*Flag` modifier chains to
  top-level `val`s instead of allocating them each composition
2026-04-26 14:54:03 +00:00
Claude 0ac8c0f791 test(audio-rooms): production round-trip via real MoQ relay (phase 3/3)
Drives connectNestsSpeaker + connectNestsListener end-to-end against the
real nostrnests Docker stack. Speaker announces a track, listener
subscribes by pubkey, speaker pushes deterministic frames through
AudioRoomBroadcaster → MoQ → relay → listener.objects flow, and the
test asserts payload integrity + monotonic object ids.

Validates the wire shapes the Phase-2 refactor committed to:
  - QuicWebTransportFactory + PermissiveCertificateValidator can
    handshake against the relay's self-signed dev cert
  - JWT minting + WebTransport CONNECT + MoQ SETUP all succeed
  - The single-segment TrackNamespace `nests/<kind>:<host>:<room>`
    matches the relay's `root` JWT claim

Single-keypair design sidesteps host-vs-audience auth policy so the
test stays focused on transport + protocol; a future dual-keypair
test can layer permissions on top.

Skipped by default — set -DnestsInterop=true to enable.
2026-04-26 14:50:00 +00:00
Claude beec8204e5 refactor(audio-rooms): NestsClient API matches nostrnests reality (phase 2/3)
The Phase-1 interop harness exposed a substantial mismatch between our
production HTTP client and what the nostrnests reference server actually
exposes. This commit refactors `:nestsClient` and the wiring above it so
the production code path can talk to a real moq-auth + moq-relay.

| Aspect    | Before                                    | After (matches nostrnests/moq-auth/src/index.ts) |
|-----------|-------------------------------------------|--------------------------------------------------|
| Method    | GET                                       | POST                                             |
| URL       | `<base>/<roomId>`                         | `<base>/auth`                                    |
| Body      | none                                      | `{"namespace":"nests/<kind>:<host>:<roomId>","publish":bool}` |
| Response  | `{endpoint, token, codec, sample_rate}`   | `{token}` only                                   |
| Endpoint  | from response                             | from event's `endpoint` tag (passed via `NestsRoomConfig.endpoint`) |
| NIP-98    | bound to GET URL                          | bound to POST URL + body hash                    |

Type changes:
- New `NestsRoomConfig` data class bundling (authBaseUrl, endpoint,
  hostPubkey, roomId, kind). Built by the caller (UI / VM) from the
  NIP-53 kind 30312 event before invoking connectNests*.
- `NestsRoomConfig.moqNamespace()` produces the exact format
  moq-auth's NAMESPACE_REGEX expects: `nests/<kind>:<hex64>:<roomId>`.
- `NestsRoomInfo` deleted; replaced with a tiny `NestsTokenResponse(token)`
  matching the real response shape.
- `NestsClient.resolveRoom(serviceBase, roomId, signer): NestsRoomInfo`
  → `NestsClient.mintToken(room, publish, signer): String`. The
  publish flag drives the JWT claims (`get` for listeners, `put`
  for speakers).

Wire path:
- `OkHttpNestsClient` now POSTs `<authBase>/auth` with a JSON body
  and a NIP-98 Authorization header bound to (POST, url, body-hash).
- `connectNestsListener` / `connectNestsSpeaker` take `room:
  NestsRoomConfig` instead of split (serviceBase, roomId), pass
  `publish=false` / `publish=true` respectively, and use the room's
  `endpoint` (not a server-returned one) for the WebTransport
  connect. The minted JWT is the bearer token.
- `NestsListenerState.Connected` / `NestsSpeakerState.Connected` /
  `Broadcasting` carry the `room: NestsRoomConfig` instead of the old
  `roomInfo: NestsRoomInfo`.
- MoQ TrackNamespace for the room is now a single segment whose
  bytes are `room.moqNamespace()` — the simplest mapping to the
  relay's JWT claim check (`root: "<namespace>"`); Phase-3 round-trip
  test will confirm and adjust if the relay expects a multi-segment
  tuple.

Wiring above:
- `AudioRoomViewModel` constructor: replaces `(serviceBase, roomId)`
  with `(room: NestsRoomConfig)`. Connector seam interfaces
  (NestsListenerConnector, NestsSpeakerConnector) follow the same
  shape.
- `AudioRoomViewModelFactory` (Android) takes `room: NestsRoomConfig`.
- `AudioRoomActivity` adds `EXTRA_AUTH_BASE_URL`, `EXTRA_ENDPOINT`,
  `EXTRA_HOST_PUBKEY`, `EXTRA_KIND` Intent extras (was just service
  + roomId) and reconstructs `NestsRoomConfig` in onCreate. Drops
  `EXTRA_SERVICE_BASE`.
- `AudioRoomJoinCard` reads `event.endpoint()` + `event.pubKey` +
  `event.kind` in addition to `event.service()`; rooms missing any
  of those are silently un-joinable (the event author didn't host
  on a nests-compatible relay).
- `AudioRoomActivityContent` takes `room: NestsRoomConfig` in place
  of (serviceBase, roomId) and threads it down.

Phase-1 ping test rewired to use the production `OkHttpNestsClient`
end-to-end against the real `/auth`, asserting we get back a
3-segment JWT.

Existing in-process tests updated for the new types: NestsConnectTest,
NestsSpeakerTest, AudioRoomViewModelTest. NestsRoomInfoTest renamed to
NestsRoomConfigTest with new cases for the namespace formatter and the
auth-URL helper. All 80 in-process tests still green.

Phase 3 (next) will add the full round-trip interop test that runs
production `connectNestsListener` + `connectNestsSpeaker` through the
real moq-relay — that's where MoQ wire-format assumptions (draft
revision, OBJECT_DATAGRAM layout, namespace tuple shape) get verified
or get followup audit findings.
2026-04-26 14:42:26 +00:00
Vitor Pamplona 864d14379a Merge pull request #2587 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-26 10:31:59 -04:00
Crowdin Bot 5c9bb64ab6 New Crowdin translations by GitHub Action 2026-04-26 14:31:16 +00:00
Vitor Pamplona 5be38ef3ac Merge pull request #2586 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-26 10:29:45 -04:00
Vitor Pamplona 2bd655e31d Merge pull request #2584 from vitorpamplona/claude/optimize-video-loading-5opqr
Optimize video playback with warm player pool and buffer tuning
2026-04-26 10:29:35 -04:00
Claude a8e7c6c598 revert(video): drop the videoPlayerButtonItemsFlow remember
The remember(accountViewModel) { ... } I added was cargo-cult. The
getter is just a chain of val property accesses
(account.settings.syncedSettings.videoPlayer.buttonItems) returning the
same StateFlow instance every call. collectAsStateWithLifecycle keys on
that flow reference, which is identity-stable, so re-calling the getter
on every recompose costs nothing meaningful and doesn't cause a
re-subscription. Inline back to the original one-liner.
2026-04-26 14:27:22 +00:00
Claude 45fb5119e8 revert(video): two cleanups from the round-4 pass that didn't earn their keep
- GifVideoView: revert the dimensions remember() to the original one-line
  expression. Unlike VideoView's equivalent block, GifVideoView only
  *reads* — there's no MediaAspectRatioCache.add() side effect to gate.
  The replaced code spent three slot reads + three equality checks per
  recompose to skip an int division and an LruCache.get(), neither of
  which allocates. It was a wash at best, a small loss at worst. The
  original is simpler and roughly the same cost.

- PlaybackServiceClient: bump the executor from newSingleThreadExecutor()
  back up to newFixedThreadPool(4). The work per listener is genuinely
  trivial in the steady state, but a single thread leaves us exposed to
  one stuck listener (e.g. the defensive 5s controllerFuture.get()
  timeout actually firing) stalling every other video on screen behind
  it. With a feed often holding several visible videos at once, that's
  a real regression risk. A fixed pool of 4 keeps us bounded against
  churn while letting independent listeners proceed in parallel.
2026-04-26 14:25:25 +00:00
Claude 3283d302fa test(audio-rooms): nostrnests interop harness + /auth ping (phase 1/3)
Brings up the nostrnests reference server (https://github.com/nostrnests/nests)
locally via Docker Compose so we can drive `:nestsClient`'s production
code against the real MoQ relay + NIP-98 auth sidecar.

Mirrors the `:quic` `InteropRunner` pattern (aioquic Docker, opt-in via
`-DinteropHost=…`):
- Set `-DnestsInterop=true` to enable; default `:nestsClient:jvmTest` runs
  skip via JUnit `Assume.assumeTrue` (shown as <skipped>, not <failure>).
- Repo cloned + cached at `~/.cache/amethyst-nests-interop/nests/`,
  pinned to the `DEFAULT_REVISION` (currently `main`; override via
  `-DnestsInteropRev=<sha>` to lock in for reproducibility).
- `docker compose -f docker-compose-moq.yml up -d` brings up moq-relay
  (host 4443 TCP+UDP), moq-auth (host 8090), strfry (7777). Port-probes
  4443 + 8090 with a 90 s timeout.
- `close()` runs `docker compose down -v --remove-orphans`. Tests use
  `@BeforeClass`/`@AfterClass` to amortise the ~30-60 s spin-up across
  all cases in one class.

Phase-1 ping test (NostrNestsAuthInteropTest):
- Generates an ephemeral KeyPair / NostrSignerInternal via Quartz.
- POSTs `<authBase>/auth` with `{"namespace":"nests/30312:<pubkey>:<roomId>",
  "publish":true}`, NIP-98 Authorization header signed for that exact
  (url, method, payload) tuple.
- Asserts 200 + a `"token":"…"` JWT in the response body.

Doesn't yet route through `OkHttpNestsClient` because the production
client's wire shape (GET `<base>/<roomId>` returning `{endpoint, token,
codec, sample_rate}`) does not match nostrnests' actual API (POST
`<base>/auth` with `{namespace, publish}` body, returning just `{token}`
— endpoint comes from the NIP-53 event's `endpoint` tag instead of the
HTTP response). Phase 2 of this audit refactors production to match;
this test documents the divergence on the wire so the refactor has a
clear target.

Verified: harness compiles clean; default `:nestsClient:jvmTest` shows
the test as <skipped> (not <failure>) when `nestsInterop` property is
unset.

Files:
- nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt
- nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsAuthInteropTest.kt
2026-04-26 14:24:44 +00:00
Crowdin Bot 294ac3e74e New Crowdin translations by GitHub Action 2026-04-26 14:01:51 +00:00
Vitor Pamplona d9b0f034f6 Merge pull request #2582 from vitorpamplona/claude/fix-translation-rendering-9fgk4
fix(translation): bug, perf and jitter overhaul of rich-text translation
2026-04-26 10:00:23 -04:00
Vitor Pamplona 5ee09dfc04 Merge pull request #2583 from vitorpamplona/claude/fix-sqlite-parallel-inserts-Zx0Eu
Make event store operations async with coroutine support
2026-04-26 09:59:13 -04:00
Claude d7bd78cc32 chore(video): correctness and hygiene cleanups in playback layer
Round-up of the small leftovers from the audit. None move the needle on
their own; together they remove a real cancellation bug and tighten the
playback types.

- PlaybackServiceClient.executorService: Executors.newCachedThreadPool()
  → Executors.newSingleThreadExecutor(). The work per callback is
  Future.get() on an already-completed future plus a non-blocking
  trySend; a single thread is plenty. The previous unbounded pool could
  spin up a thread per concurrent video, each lingering for the 60 s
  keep-alive afterwards.

- MediaControllerState.controller: var → val. The field was never
  reassigned anywhere (grep confirms), and a non-observable var on a
  @Stable class is a footgun — Compose can't see writes to a plain var,
  so any future write would silently miss recomposition.

- MediaControllerState.currrentMedia() → currentMedia(). Typo. Updated
  the single caller in PipVideoView.

- LoadThumbAndThenVideoView: real cancellation bug fix. The Coil fetch
  was launched into AccountViewModel.viewModelScope via a side helper
  (loadThumb), so a scroll-away didn't cancel the in-flight image
  request — wasted bandwidth and a late callback writing into stale
  state. Inline the Coil call into the LaunchedEffect's own scope so
  cancellation propagates, and key the effect on thumbUri so a recycled
  audio-track slot with a new cover doesn't stall on the prior
  Pair(true, ...) gate. Drop the now-unused AccountViewModel.loadThumb
  and its only-here imports.
2026-04-26 13:55:53 +00:00
Claude 63b20b88d0 refactor(translation): split UI from orchestration, dedupe boilerplate
Pure-readability refactor — no behaviour change, all 410 unit tests still pass.

TranslatableRichTextViewer.kt (358 → 192 lines)
- Extract the in-line LaunchedEffect block (cache check + ML Kit await + cancellation
  bridge + result validation + caching) into a private `suspend translateAndCache`
  function. The effect body is now four lines: try/catch around one call.
- Add a small `ResultOrError.toTranslationConfig(content)` extension that returns a
  TranslationConfig only when an actual translation took place, replacing the
  five-condition inline if/else inside the effect.
- Move TranslationMessage / LangSettingsDropdown / CheckmarkRow out to a sibling
  file (TranslationStatusBar.kt). They render the "Translated from X to Y" footer
  and don't belong in the orchestrator file.

TranslationStatusBar.kt (new)
- Renamed the public composable to `TranslationStatusBar` to make its role obvious.
- Split the status text and the dropdown into separate private composables so each
  fits on screen at a glance.
- Add a tiny `LangMenuItem(checked, label, onClick)` to dedupe the four
  `DropdownMenuItem { text = { CheckmarkRow(...) }, onClick = ... }` blocks.
- Hoist `rememberDeviceLocales()` out of the dropdown body for clarity.
- Cache `settings.preferenceBetween(source, target)` once per dropdown render
  instead of calling it twice with identical args.

LanguageTranslatorService.kt
- Extract the in-flight cache plumbing into a `private inline fun dedupe(key, factory)`
  helper. `autoTranslate` is now three lines that read top-to-bottom:
  pre-filter, dedupe, identifyLanguage → translateOrSkip.
- Promote the inline `when` deciding whether to translate (matches translateTo,
  is "und", is in dontTranslateFrom) into a named `translateOrSkip` function so
  the policy is greppable.

TranslationDictionary.kt
- Add a `private inline fun Pattern.forEachMatch(text, block)` extension.
  The four near-identical `val matcher = …; while (matcher.find()) addUnique(matcher.group())`
  loops collapse to three one-liners; the URL detector loop stays explicit because
  it has its own filter.

`inline` on dedupe and forEachMatch keeps the lambda allocations gone, so this is
a zero-cost refactor at runtime.

https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5
2026-04-26 13:54:18 +00:00
Claude 3bf1448d63 docs(quartz/store): explain the connection pool and the suspend API
- Add a Concurrency section to the SQLite store README covering the
  Room-style 1-writer + N-reader pool, the in-memory degradation, and
  the non-reentrant Mutex contract.
- Refresh the SQLite "How to Use" examples to call out the suspend
  context and recommend transaction-batching for hot inserts.
- Switch the ExpirationWorker example from Worker to CoroutineWorker
  now that deleteExpiredEvents is suspend.
- Note in the FS README that the IEventStore API is suspend even
  though the FS layer keeps a synchronous flock manager (the
  withWriteLock helper is inline so suspend bodies pass through).
- Update the FsMaintenanceTest description to match the
  coroutine-based concurrency test.
- Document the Mutex non-reentrancy footgun in
  SQLiteConnectionPool's KDoc so module logic doesn't try to re-enter
  the pool from inside useWriter.

https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5
2026-04-26 13:49:47 +00:00
Claude 9fcf85bed0 fix(quartz/sqlite): serialise writes via a Room-style connection pool
androidx.sqlite SQLiteConnection is not thread-safe; SQLiteEventStore
shared a single lazy connection across all callers, so two coroutines
calling insertEvent() at the same time would race on BEGIN IMMEDIATE
and the modules' prepared statements, surfacing as
"cannot start a transaction within a transaction" or SQLITE_MISUSE.

Mirror Room's design: introduce SQLiteConnectionPool with one writer
connection guarded by a coroutine Mutex and N reader connections
handed out via a Channel-as-semaphore (file-backed DBs only; in-memory
DBs share the writer because each ":memory:" connection is a separate
DB). Convert IEventStore + SQLiteEventStore + EventStore + FsEventStore
+ LiveEventStore to suspend, route writes through useWriter and reads
through useReader. RelaySession now launches handleEvent / handleCount
on its scope. CLI Context helpers and StoreCommands.sweepExpired pick
up suspend.

Add ParallelInsertTest to lock the behaviour in: 8 coroutines × 200
inserts, parallel reads alongside writes, transaction batches across
coroutines, and a reopen smoke test all pass against a file-backed DB.

https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5
2026-04-26 13:25:30 +00:00
Claude 2a932dc974 fix(audio-rooms): clean up deferred audit items (VM + Android + MoQ comment)
Lands the audit follow-ups that didn't require external input. Only the
wire-format draft pinning (MoQ #1, #8) remains deferred — that's gated
on the M4 manual interop pass, and the same-room-PIP-re-entry corner
case (Android #5) — Android has no programmatic PIP-exit API.

ViewModel:
- VM #10: serialize disconnect→connect via a tracked `pendingCloseJob`.
  teardown() records the listener.close() launch (when not finalCleanup);
  the next launchConnect() awaits it before opening a fresh transport.
  Eliminates the brief two-QUIC-session overlap that some MoQ relays
  reject by deduping on client pubkey.
- VM #4: extracted shared connect-launch body into `launchConnect(triggerRetryOnFailure)`
  so connect() / connectInternal() share one implementation. Removed
  the near-duplicate viewModelScope.launch block.
- VM #8b: setMicMuted no longer silently swallows handle failures.
  BroadcastUiState.Broadcasting gains a `muteError: String?` field that
  the UI can surface as an inline message; cleared on the next successful
  toggle. The broadcast itself stays running with its previous mute
  state — only the mute toggle failed.
- VM #6: documented the dispatcher-confinement contract in the class
  kdoc. Audit was theoretical — every map mutation already runs on
  viewModelScope (Dispatchers.Main.immediate on Android, same dispatcher
  the MoQ flow's onEach callback uses because the player launch lives in
  viewModelScope). Future cross-thread callers must marshal explicitly.

Android:
- Android #2: AudioRoomActivity.toggleMuteSignal type tightened from
  `MutableSharedFlow<Unit>` to `SharedFlow<Unit>` so external code can't
  tryEmit into it. Internal emit uses the private `_toggleMuteSignal`.
- Android #10: presence debounce-publisher (the LaunchedEffect keyed on
  micMutedTag) now skips entirely when micMutedTag is null. Stops the
  duplicate first-frame publish where heartbeat fires immediately AND
  debounce-publisher fires 500 ms later, both with muted=null. Once the
  user goes live the debounce-publisher kicks in for state changes.

MoQ session:
- MoQ #11: send() rollback comment rewritten to say "monotonic; gaps
  acceptable per spec, this just minimises them on full-fanout failures"
  instead of the misleading "strictly contiguous" claim.

Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest
(80 tests), :amethyst:compilePlayDebugKotlin all green.

Still deferred:
- MoQ #1, #8: wire-format draft pinning (draft-17 vs draft-11). Needs
  M4 interop input from `nostrnests.com` to know what the relay actually
  speaks.
- Android #5: same-room re-entry from MainActivity while in PIP doesn't
  auto-exit PIP. Android has no programmatic PIP-exit API; user must tap
  the expand button. Corner case.
- Test coverage gaps (round-1 VM #10, round-2 VM #13): retry-counter +
  broadcast state + setMicMuted-no-handle + server-Closed cleanup +
  double-connect-while-Failed. Each is a small dedicated test using the
  existing connector-seam pattern; landing as a separate test-only commit.
2026-04-26 13:18:51 +00:00
Claude ba1a1bfc12 perf(video): shorten VideoCache warmup delay from 10s to 1.5s
The existing background warmup in Amethyst.initiate() deferred the lazy
videoCache touch by 10 seconds. SimpleCache's constructor opens a SQLite
index via StandaloneDatabaseProvider and walks every cached span on disk
— a few hundred ms on a populated 4 GB cache — so we really do not want
that running on the main thread.

But 10 s is long enough that a fast user (or a deep link / push notification
that lands directly on a video-bearing screen) can win the `lazy { }` race
and trigger init on the main thread inside PlaybackService.onGetSession,
which is exactly the hitch the warmup was meant to prevent.

Drop to 1.5 s — long enough to let the urgent first-paint work above
(account load, image loader, ui state, robohash) breathe, short enough
that a typical user can't scroll and tap a video before the warmup wins.
Document the trade-off in a comment so the timing isn't a magic number.
2026-04-26 13:15:08 +00:00
Claude c6b275e7fe perf(video): tighten remember keys and stabilize controller-overlay tree
Round-4 audit cleanups. Each item is small but each runs on the hot path
that recomposes during every active video, so they add up while scrolling.

P1 — DimensionTag identity invalidating remember:
- DimensionTag (in quartz) is a regular class with no equals override, so
  reference equality means a freshly parsed tag for the same event is !=
  to the previous one. The remember(videoUri, dimensions) blocks added in
  the earlier perf commits were re-running their lambda on every recompose.
  Switch to primitive (width, height) keys in VideoView and GifVideoView so
  the cache lookups + MediaAspectRatioCache writes only fire when the
  dimensions actually change.

P1 — Static gradient brushes:
- TopGradientOverlay / BottomGradientOverlay were calling
  Brush.verticalGradient(colors = colors) inside the modifier chain, which
  allocated a fresh Brush on every recomposition while the controllers
  were visible (i.e. on every active video most of the time). Pre-build
  both brushes as file-level vals so they're allocated exactly once per
  process.

P2 — ImmutableList for action collections:
- RenderTopButtons / AnimatedOverflowMenuButton / OverflowMenuButton were
  passing List<VideoPlayerAction> across composable boundaries. Plain List
  is unstable in Compose, forcing the overflow tree to recompose any time
  an unrelated parent state (volume, tracks, controllerVisible) ticked.
  Use ImmutableList end-to-end via toImmutableList() at the producer side.

P2 — videoPlayerButtonItemsFlow remember:
- accountViewModel.videoPlayerButtonItemsFlow() was being called fresh
  every recomposition, with the result handed straight to
  collectAsStateWithLifecycle. Hoist the call into remember(accountViewModel)
  so the flow reference is stable.

P2 — MuteButton dispatcher cleanup:
- The 2-second hold timer was using LaunchedEffect { launch(Dispatchers.IO)
  { delay(2000); holdOn.value = false } }. The wrapped launch was just
  redundant dispatcher hopping — delay() doesn't hold a thread and the
  Compose write is fine on Main. Inline it.
2026-04-26 12:37:56 +00:00
Claude ed793e8eb3 fix(audio-rooms): round-2 audit — pump self-join (CRITICAL) + 5 other findings
Round 2 audit (3 parallel agents reviewing every change since the
previous follow-up commit) caught one CRITICAL regression and several
HIGH/MED items. Most round-1 fixes verified clean.

CRITICAL fix (audit round-2 MoQ #1):
- Pump exception handlers added in the previous commit call `close()`
  from inside the failing pump's own coroutine. `close()` now does
  `controlPumpJob?.join()` to drain in-flight writes — but the Job we
  try to join is the very Job we're inside, so `join()` suspends
  forever (lambda can't finish until close returns; close can't
  return until lambda finishes). `runCatching` doesn't help — `join()`
  doesn't throw, it suspends. This deadlocks the entire session
  whenever a pump fails.
  Fix: skip the join when the current coroutine IS the job we're
  joining. `currentCoroutineContext()[Job]` identifies the caller; we
  compare and bypass.

HIGH fixes:
- MoQ #2 (regression): concurrent unannounce() + post-OK
  AnnounceError handler could both write UNANNOUNCE on the wire (some
  relays disconnect on UNANNOUNCE for an unknown namespace). Fix:
  `AnnounceHandleImpl.unannounceWritten: Boolean` flag, set under
  stateMutex by whichever writer goes first; the other path skips.
- VM #3 (new): `connect()` overwrote `listener` if invoked from a
  Failed-with-stale-listener state, leaking the previous MoQ session.
  Fix: call `teardown(targetState=Idle, finalCleanup=false)` before
  launching the new attempt when listener or stateObserverJob is
  still alive.
- VM #7 (new): `openSubscription` allocated decoder + player via the
  factories, then attached them to the slot. If the VM scope was
  cancelled between `decoderFactory()` and `slot.attach(...)`, the
  native MediaCodec / AudioTrack leaked because nothing was tracking
  them yet. Fix: nest a try/catch that releases both on any throw
  (including `CancellationException`) before re-throwing.

MED fixes:
- MoQ #7 (new): `capture.start()` could throw before `job` was
  assigned, leaving the broadcaster in a half-started state where
  future `start()` calls would re-pass the guards and double-start
  the mic. Fix: try/catch around capture.start; on throw, set
  `stopped = true` + run capture.stop and propagate.
- MoQ #8 (new): `stopped` was read across threads (setMuted from
  caller, stop from anywhere) without a `@Volatile` barrier.
  Visibility hazard. Fix: `@Volatile private var stopped`.
- Android #12 (new): after the user granted RECORD_AUDIO via the
  Settings deep-link, `permissionDenied` stayed `true` because the
  launcher callback never fired — the warning + Open-settings button
  remained visible until the user tapped Talk again. Fix: derive
  `showDenialWarning` from `permissionDenied AND
  ContextCompat.checkSelfPermission(...) != GRANTED`. Re-checks every
  recomposition (including post-Settings return).

Round-1 fixes verified clean by this audit:
- pending-deferred completeExceptionally on close
- SubscribeDoneStatus codes (UNSUBSCRIBED=0x00, TRACK_ENDED=0x03)
- suspend `stop()` conversions on broadcaster + player
- gate release before NestsSpeaker teardown chain
- unannounce() ordering on thrown wire-write
- SharedFlow PIP signal (rapid double-tap behavior is correct)
- RECEIVER_NOT_EXPORTED gate (constant 4 doesn't collide; round-1
  collision claim was incorrect)
- onUserLeaveHint guards (PIP from lobby, no PIP support)
- foreground service `startForeground`-always-first contract
  (`Result.onFailure` is `inline`, the `return` IS a non-local
  return from `onStartCommand` — verified)
- 4-hour wake-lock cap
- AudioRoomBridge.clear() in AccountViewModel.onCleared

Still deferred:
- VM #6 (round-1 carryover): unsynchronized speakingExpiryJobs map.
  Cross-thread mutation under contention. Needs ConcurrentHashMap or
  Dispatchers.Main.immediate marshalling.
- VM #10 (round-2 new): brief two-QUIC-session overlap during
  rapid disconnect→connect.
- Android #5 (round-2 new): same-room re-entry from MainActivity
  while in PIP doesn't auto-exit PIP.
- MoQ #1, #8 (round-1 carryover): wire-format draft pinning. Still
  blocked on M4 manual interop input.

Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest
(80 tests), :amethyst:compilePlayDebugKotlin all green.
2026-04-26 12:32:22 +00:00
Claude 0b1ec52f79 fix(audio-rooms): audit follow-up — MoQ HIGH/MED + VM concurrency + Android polish
Round 2 of the audit-driven cleanup. Lands every HIGH and most MED
findings from the protocol / ViewModel / Android lifecycle audits that
weren't fixed in the previous commit. The wire-format draft pinning
(audit MoQ #1, #8) is intentionally deferred until the M4 manual interop
pass against `nostrnests.com` reveals which draft revision the relay is
actually speaking.

MoQ session fixes:
- #5 UNANNOUNCE wire-write now happens BEFORE removing announces[ns],
  so an inbound SUBSCRIBE during the teardown window sees the namespace
  as withdrawn (sessionClosed=true → SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST))
  instead of "namespace never existed".
- #4 dispatchControlMessage(AnnounceError) now distinguishes pre-OK and
  post-OK errors. Post-OK is a session-level kick: mark the handle
  closed, send UNANNOUNCE, then drop. Pre-OK still rolls back the
  optimistic announces[] insert as before.
- #6 close() now joins the cancelled control + datagram pumps before
  calling controlStream.finish(), so an in-flight SUBSCRIBE_OK /
  SUBSCRIBE_DONE / SUBSCRIBE_ERROR write can complete its
  writeMutex.withLock { ... } critical section. Previously cancellation
  could truncate a frame mid-flight and we'd send FIN over a corrupted
  stream.
- #9 pumps wrapped in try/catch that calls close(...) on unexpected
  exceptions, so a transport-died-mid-session no longer leaves the
  session thinking it's healthy with new subscribe/announce calls
  hanging on a dead peer.
- #10 TrackPublisher.send rolls back nextObjectId when every datagram
  fan-out fails (transport down). The audio-rooms NIP wants strictly
  contiguous object ids per group; a gap from a fully-failed send
  would trip strict subscribers.
- #13 DefaultNestsSpeaker.close drops `gate` before calling
  activeHandle.close() / session.close(). The teardown chain runs
  cancelAndJoin on the broadcaster + sends SUBSCRIBE_DONE per attached
  subscriber + joins MoQ pumps; holding the gate through all of that
  blocked any other concurrent API call on this speaker.

Resource lifecycle (audit MoQ #11/#12):
- AudioRoomBroadcaster.stop() now `cancelAndJoin`s the loop before
  releasing the encoder + closing the publisher. The loop's last
  encoder.encode/publisher.send no longer races
  encoder.release()/publisher.close() — both produced use-after-release
  on native MediaCodec on Android, the latter sent orphan
  OBJECT_DATAGRAMs to subscribers we'd just told SUBSCRIBE_DONE.
- AudioRoomPlayer.stop() promoted to `suspend` + cancelAndJoin for the
  same reason: decoder.release() ran while the decode loop was still
  inside MediaCodec.decode(...), undefined behaviour. Updated VM call
  sites (closeSubscription, teardown) to route both player.stop() and
  handle.unsubscribe() through one launched coroutine via the new
  `detach(): Pair<AudioRoomPlayer?, SubscribeHandle?>` shape.

ViewModel fixes:
- #4 auto-retry uses a single `retryPending: Boolean` flag instead of
  `Job.isActive`. Two scheduleAutoRetry calls could previously both
  pass `Job.isActive == false` (the launched body had just started)
  and stack a second retry on top of one already running.
- #7 setMicMuted updates the UI INSIDE the launched coroutine, after
  the suspending broadcastHandle.setMuted() returns. Previously the
  indicator could claim "muted" while audio was still on the wire if
  the handle's setMuted suspended on a gate.
- #8 connect() cancels the previous stateObserverJob before kicking
  off the new attempt, so a delayed Failed/Closed emission from the
  old listener can no longer clobber the fresh Connecting UI.
- #9 disconnect() clears requestedSpeakers, so a fresh connect() to a
  different room (or the same room after a long pause) doesn't reuse
  a stale speaker snapshot.
- #12 updateSpeakers filters out the user's own pubkey: subscribing
  to your own forwarded audio would echo through the local playback
  device whenever the broadcast track loops back from the relay.

Android lifecycle / PIP / service:
- #6 PIP aspect ratio flipped from 9:16 (portrait sliver) to 16:9
  (landscape) so the row of avatars under the title actually fits.
- #7 process-death recovery: when AudioRoomBridge is empty (previous
  process's AccountViewModel is gone), redirect to MainActivity
  before finish() so the user lands somewhere meaningful instead of
  a black-flash.
- #8 AudioRoomForegroundService.onStartCommand always calls
  startForeground first, on every invocation including ACTION_STOP.
  startForegroundService's 5-second contract requires it; previously
  the STOP path skipped it. startForeground itself wrapped in
  runCatching so a foreground-not-allowed exception bails cleanly
  rather than leaking the wake-lock.
- #10 wake-lock timeout reduced 12 h → 4 h. Stuck connections that
  fail to detect a network drop no longer hold the device awake for
  half a day.
- #11 presence-event spam fix: split the publish loop into a
  heartbeat keyed only on (address, handRaised) and a separate
  debounced state-change publisher keyed on micMutedTag. Every mute
  toggle previously triggered a full sign + publish + relay round
  trip; now we coalesce within a 500 ms window.
- #12 final "leaving" presence routed through GlobalScope.launch
  instead of rememberCoroutineScope (which is cancelled on dispose,
  so the leave event almost never reached the relay).
- #14 RECORD_AUDIO denial recovery: when the user has tapped "Don't
  ask again", the launcher silently returns false. New "Open
  settings" button deep-links to the app's settings page so the user
  can re-grant the permission and try again.
- #15 setPictureInPictureParams now updates outside PIP too, so the
  next entry shows the correct mute-state icon without an extra flip.
- #16 onNewIntent override: a second Join tap for a different room
  finishes the current Activity and starts a fresh one with the new
  extras, instead of singleTask silently keeping the old room
  running.

Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest
(80 tests), :amethyst:compilePlayDebugKotlin all green.

Audit findings still deferred (all documented inline / in this commit):
- MoQ #1, #8: wire-format draft pinning (draft-17 vs draft-11). Needs
  the M4 manual interop pass to confirm what nests is actually speaking.
- VM #6: confined map mutation under a single dispatcher. Current
  setup (Dispatchers.Main via setMain in tests, viewModelScope in
  prod) is functionally fine; full belt-and-suspenders confining is a
  separate concurrency review.
- VM #10: test coverage gaps for retry + speaker reconcile cycle +
  setMicMuted no-handle case + server-initiated Closed leaves stale
  state. Each is a dedicated test.
- Android #18: startListening / promoteToMicrophone race. Mitigation
  (always declaring microphone foreground type) requires unconditional
  RECORD_AUDIO grant which listener-only users won't have.
2026-04-26 12:04:18 +00:00
Claude f0b27654ba test(audio-rooms) + fix: round-trip test + audit pass
Adds an end-to-end MoQ round-trip test and lands the highest-severity
findings from a 3-agent audit (protocol / ViewModel / Android lifecycle)
of the M5–M7 + Activity work.

Round-trip test (`:nestsClient` MoqRoundTripTest):
- Two MoqSession.client() instances (publisher + subscriber) talk
  through a hand-rolled in-test relay coroutine that mirrors a real
  MoQ relay's wire behavior (forwards CLIENT_SETUP / SERVER_SETUP /
  ANNOUNCE / SUBSCRIBE / SUBSCRIBE_OK / OBJECT_DATAGRAM).
- 100-Opus-frame test exercises the full publisher → wire →
  subscriber path, asserting payload bytes + monotonic group/object
  ids round-trip correctly. Catches any drift between our publisher
  and our own subscriber's wire format.
- Second test verifies SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST) flows
  back as MoqProtocolException when the publisher hasn't openTrack'd.

MoQ protocol fixes (CRITICAL audit findings):
- SubscribeDoneStatus constants were inverted: had UNSUBSCRIBED=0x01
  (peer reads as INTERNAL_ERROR) and TRACK_ENDED=0x00 (peer reads as
  UNSUBSCRIBED). Swapped to draft-stable values: UNSUBSCRIBED=0x00,
  TRACK_ENDED=0x03.
- Pending CompletableDeferreds for in-flight SUBSCRIBE / ANNOUNCE on
  session close were `cancel()`-ed, which propagates as
  CancellationException — caller's entire scope cancels instead of
  catching a domain MoqProtocolException. Switched all sites to
  `completeExceptionally(MoqProtocolException("session closed"))`
  including unsubscribe-while-pending-OK.

ViewModel fixes:
- openSubscription race: re-check `activeSubscriptions[pubkey] === slot
  && !closed` AFTER the suspending `subscribeSpeaker` returns; if the
  user removed the speaker mid-flight, fire-and-forget UNSUBSCRIBE
  rather than attaching a leaked SubscribeHandle + AudioRoomPlayer to
  a discarded slot.
- Server-initiated `Closed` listener state now triggers
  `teardown(targetState=Closed)` and resets the auto-retry counter,
  so a transport-died-mid-handshake doesn't leave stale subscriptions
  in the VM map until the Activity finishes.
- Cleanup-scope split: `disconnect()` (user-driven) routes the close
  through `viewModelScope` (still alive); `onCleared()` routes it
  through a process-lived `cleanupScope` (default GlobalScope, tests
  pass backgroundScope) so MoQ control frames (UNSUBSCRIBE,
  UNANNOUNCE, SUBSCRIBE_DONE) actually land before the QUIC
  transport drops, instead of being eaten by the cancelled
  viewModelScope.

Android lifecycle fixes:
- AudioRoomBridge.clear() wired into AccountViewModel.onCleared next
  to the existing CallSessionBridge.clear() — no more cross-account
  AccountViewModel leak after logout/switch.
- registerReceiver flag now gated on Build.VERSION.SDK_INT
  TIRAMISU+ — RECEIVER_NOT_EXPORTED on pre-33 devices collides with
  RECEIVER_VISIBLE_TO_INSTANT_APPS. PendingIntents stay
  package-scoped via setPackage(packageName).
- onUserLeaveHint no longer enters PIP unconditionally: gated on
  ui.connection == Connected AND PackageManager
  FEATURE_PICTURE_IN_PICTURE present, so PIP-from-lobby /
  PIP-on-incompatible-device doesn't leave a frozen full-screen
  card in Recents.
- Replaced the process-wide singleton AudioRoomPipActions toggle
  Boolean with a per-Activity MutableSharedFlow<Unit> so a stale
  emission from a torn-down Activity can't leak into a new one.

ViewModel test was extended with `cleanupScope = backgroundScope`
plumbing so the post-disconnect `listener.close()` assertion remains
observable in the test scheduler.

Verified: spotlessApply clean; :commons:jvmTest (9 tests),
:nestsClient:jvmTest (80 tests including 2 new round-trip), and
:amethyst:compilePlayDebugKotlin all green.

Audit findings deferred to follow-up commits (none ship-blocking):
- Wire layout pinning vs draft-17 vs draft-11 (currently emits a
  draft-11-style OBJECT_DATAGRAM; we advertise draft-17). Resolve
  via the M4 manual interop pass against nostrnests.
- UNANNOUNCE racing inbound SUBSCRIBE; control-pump cancel half-write
  (audit MoQ #5, #6) — small ordering tweaks.
- Two retry coroutines stacking under fast Failed bursts (audit VM #4).
- AudioRoomForegroundService startForeground always-first contract
  (audit Android #8).
2026-04-26 09:05:00 +00:00
Claude c9a19b90f0 perf(video): retain warm ExoPlayers and clear up the controller hot path
Closes the remaining audit items so the eager-prepare model also pays off
on scroll-back. The big change is keeping the most recent N feed players
paused-with-buffer instead of stop()'ing them on release.

P0 — Warm-slot ExoPlayer pool:
- ExoPlayerPool now retains up to N (default 3) paused players keyed by
  the mediaId they last loaded. Acquire takes an optional preferredMediaId
  hint and returns the matching warm player intact; only the cold fallback
  path runs stop()/clearMediaItems(). Warm slots count against the
  device's MediaCodec budget (poolSize) so the cold cap is poolSize -
  warmSize, with warm slots themselves capped at poolSize-1 to guarantee
  there's always at least one cold slot for a brand-new URI.
- Plumb videoUri through connection hints (PlaybackServiceClient ->
  PlaybackService.onGetSession -> MediaSessionPool.getSession ->
  ExoPlayerPool.acquirePlayer) so the service can find a warm match.
  Constants for the bundle keys live on PlaybackService.
- GetVideoController.onEach now checks
  state.controller.currentMediaItem?.mediaId before calling setMediaItem.
  On a warm hit it leaves the player and its buffer alone — calling
  setMediaItem in that case would reset the player and undo the whole
  point of the warm pool. STATE_IDLE survivors still get a re-prepare.

P1 — Stop rebuilding the MediaController on transient lifecycle dips:
- GetVideoController.collectAsStateWithLifecycle was tearing down and
  re-binding the MediaController every time the activity lifecycle
  dropped below STARTED (system dialogs, briefly switching apps,
  notification shade). Switch to plain collectAsState — the controller
  now lives until the composable actually leaves composition, so a
  brief lifecycle dip no longer costs a full IPC rebind + buffer
  reload. Real backgrounding still tears down via composable disposal.

P2 — Smaller fixes flushed at the same time:
- GetVideoController: only write controller.volume when it differs
  from target. Combined with the new dedup, several feed videos
  preloading no longer fire one volume IPC per ready callback.
- CurrentPlayPositionCacher: the resume threshold was `5 * 60`, which
  in milliseconds is 300 ms — i.e. "always seek". Bump to 5_000 (5 s)
  so trivially short clips don't pay an extra seek + buffer flush at
  STATE_READY just to land 100 ms away from where they started.
- MediaSessionPool: stop allocating a fresh DataSourceBitmapLoader per
  session — the loader has no per-session state, so it's now a single
  lazy instance shared across all sessions in a pool.
- MediaSessionPool.cleanupUnused was racy: concurrent releases all won
  the time check and each launched a redundant sweep coroutine. Replace
  with a CAS-guarded AtomicLong on a nano-precision timestamp.
2026-04-26 08:49:48 +00:00
Claude 91d194b11e test(translation): JVM unit tests for placeholder dictionary round-trip
Extracts the placeholder dictionary logic out of LanguageTranslatorService into a
pure-JVM TranslationDictionary helper so the round-trip can be unit-tested
without ML Kit / Android runtime, and adds 24 tests covering it.

The existing TranslationsTest is androidTestPlay-only — it needs a real device or
emulator with Google Play services, so it can't validate a refactor in plain CI
or local dev. The new tests cover the riskiest part of this branch: that PUA
placeholders survive an arbitrary "translation" of the surrounding text and
decode back to the exact original tokens.

Test coverage
- isWorthTranslating: short text / letterless text rejected, mixed letters+emoji accepted
- placeholder: produces single PUA codepoint, rejects out-of-range index
- build: collects URLs, NIP-19 nostr refs, Lightning invoices, NIP-08 #[N] refs
- build: deduplicates repeated occurrences and skips Chinese-punctuation URL false-positives
- encode/decode round-trip on plain URLs, multi-URL strings, and mixed real-world content
- encode replaces longer values first to avoid prefix collisions
- decode preserves user text containing the OLD "B0/C0/A0" tokens (regression for the
  pre-rewrite collision bug)
- case-sensitive replacement preserves user text that differs only in case from a placeholder
- decode handles null and empty-dictionary inputs
- simulated translation (rewrite English to Portuguese around the placeholders) round-trips
  #[0] and nostr:nevent1... unchanged

LanguageTranslatorService now delegates to TranslationDictionary.{build, encode,
decode, isWorthTranslating} — public API (autoTranslate / translate /
identifyLanguage / clear) and behaviour are unchanged.

Result: 410 tests run, 408 passed, 2 pre-existing skips, 0 failures.

https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5
2026-04-26 08:45:04 +00:00
Claude c1c52aa022 fix(deb): broaden libicu Depends so .deb installs across Debian/Ubuntu
jpackage runs `dpkg-shlibdeps` against the bundled JDK runtime's native
libs (libfontmanager.so etc. link libicu) and pins Depends to the build
host's libicu version. CI builds on ubuntu-24.04, so the .deb requires
libicu74 — uninstallable on Ubuntu 22.04 (libicu70), Debian 12 (libicu72),
Debian 11 (libicu67), and Debian 13 (libicu76). Neither jpackage nor the
Compose Multiplatform DSL exposes a way to override the auto-generated
Depends.

Add scripts/relax-deb-libicu.sh which extracts the .deb with `dpkg-deb -R`,
rewrites `libicuNN` (or any alternation thereof) to
`libicu66 | libicu67 | libicu70 | libicu72 | libicu74 | libicu76 | libicu77`,
and repacks. The script is idempotent and a no-op for .debs without a
libicu Depends.

Wire it into both release legs (desktopApp + amy CLI) in
create-release.yml, and into the desktop test/build leg in build.yml so
testers downloading the CI artifact hit the same fix.
2026-04-26 10:21:07 +02:00
David Kaspar de31d37c01 Merge pull request #2580 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-26 10:14:18 +02:00
Crowdin Bot ed2419b6c7 New Crowdin translations by GitHub Action 2026-04-26 07:37:23 +00:00
David Kaspar 30c2cca8a4 Merge pull request #2578 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-26 09:35:53 +02:00
Claude 24b8fa12b4 fix(translation): bug, perf and jitter overhaul of rich-text translation
Fixes a cluster of issues in TranslatableRichTextViewer + LanguageTranslatorService
that caused stale translations, redundant ML Kit work, and visible jitter on every
note that scrolls into view.

Bugs fixed
- Effect now actually re-runs when "Translate to" / "Don't translate from" change.
  Previously LaunchedEffect(Unit) snapshotted the settings once and ignored
  subsequent updates.
- Translation cache now keys on (content, translateTo, dontTranslateFrom) instead
  of just content, so changing the target language no longer serves a stale
  translation in the wrong language.
- Cancelled / "no translation needed" outcomes are now cached, so language
  identification no longer re-runs on every recomposition / scroll-back of text in
  the user's own language or in the don't-translate set.
- ML Kit Tasks are now awaited via kotlinx.coroutines.tasks.await with
  ensureActive() checks; cancelling the composable's coroutine no longer races
  against an in-flight callback that mutates Compose state after disposal.
- Encoded placeholders no longer collide with arbitrary user text. Replaced the
  old "B0/C0/A0" tokens (which a user could legitimately type) with single
  Unicode Private Use Area codepoints, and made replacement case-sensitive so
  e.g. "b0" in body text is no longer rewritten on decode.
- Translation pipeline propagates failures: continueWith now rethrows
  task.exception instead of silently calling .result on a failed sub-task.
- buildDictionary protects legacy NIP-08 references (#[N]) via the placeholder
  table, replacing the fragile post-translation "# [" -> "#[" string fix.

Performance
- LanguageTranslatorService de-duplicates concurrent translation requests for the
  same (text, settings) via an in-flight ConcurrentHashMap, so reposts /
  notifications / threads sharing the same content fire one ML Kit pipeline
  instead of N.
- executorService is now a private bounded fixed pool sized on
  availableProcessors() / 2 instead of a publicly-mutable unbounded cached pool
  that could spawn dozens of threads under heavy scroll.
- Skip ML Kit entirely for texts shorter than 4 chars or with no letter
  codepoints (emoji-only, punctuation) — language identification is unreliable
  there anyway.
- Translation cache bumped from 100 to 500 entries to cover long threads /
  long-form articles.
- Single-call translation (one ML Kit call for the whole text) preserves
  sentence-level context across paragraphs that the old per-line split discarded.

Jitter
- Removed CrossfadeIfEnabled around the rich-text body. The old code rendered two
  full RichTextViewer trees (and re-parsed URLs / hashtags / NIP-19 references
  twice) during the ~300ms crossfade whenever a translation arrived. Body now
  swaps directly; only the translation toggle hint sits below.
- Replaced derivedStateOf around a trivial ternary with a plain expression.
- Locale.forLanguageTag(...).displayName memoized per source/target tag so the
  CLDR display-name lookup doesn't run on every recomposition of the
  "Translated from X to Y" hint.
- Device-locale list lifted out of the dropdown render loop and remembered, so
  ConfigurationCompat.getLocales no longer fires per recomposition while the
  language menu is open.
- Dropdown body is only composed when expanded — it was already cheap inside
  Material3's DropdownMenu, but skipping the wrapper composition entirely is
  measurably tighter.

The exposed API (LanguageTranslatorService.autoTranslate / .translate /
.identifyLanguage / .clear, ResultOrError) is unchanged; TranslatableRichTextViewer's
two public composables keep their signatures, so the ~30 call sites and the
existing TranslationsTest don't need any updates. TranslationConfig drops the
showOriginal field — that toggle is now derived live from
AccountLanguagePreferences.preferenceBetween(...) so changing the user's
language preference is reflected immediately without invalidating the cache.

https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5
2026-04-26 04:12:35 +00:00
Claude a11ba2e55d perf(video): warm up ExoPlayer pool, tune LoadControl, fix notification fall-through
Three small infrastructure fixes that support the eager-prepare model
(every visible feed video calls setMediaItem + prepare immediately so
it's ready when the user scrolls to it).

- ExoPlayerPool.create(): the warmup that builds poolStartingSize
  players up front was already written but never invoked. Wire it from
  PlaybackService.lazyPool() the first time a pool is requested. The
  builds now run on the pool's main-looper scope with a yield() between
  each so they're spread across frames instead of stalling the UI in
  one ~150–600 ms burst. Idempotent via an AtomicBoolean.

- ExoPlayerBuilder: install a feed-tuned DefaultLoadControl
  (10s/15s/750ms/2000ms) instead of the 50s/50s/2.5s/5s defaults. Every
  visible video preloads, so 5 simultaneous players were each trying to
  buffer 50s ahead — fighting for network and burning ~30 MB of buffer
  per HD player. Capping at 15s keeps the active video smooth, lets it
  start playing as soon as ~750 ms is buffered, and slashes peak memory
  on feeds with several preloads.

- PlaybackService.onUpdateNotification: the third forEachIndexed loop
  was missing its return, so on the muted-but-playing fallback path
  super.onUpdateNotification was called once per playing session
  instead of once total. With multiple feed videos preloading
  simultaneously this was hammering the notification system every time
  a player changed state. Match the first two loops by returning after
  the first match. Also drop the unused `idx` from forEachIndexed.
2026-04-26 04:01:59 +00:00
Claude 3816e471b7 refactor(audio-rooms): dedicated AudioRoomActivity with PIP — replaces in-channel AudioRoomStage
Moves the entire audio-room session into its own Activity with
Picture-in-Picture support. The chat-room screen (ChannelView) now shows
a thin lobby card (`AudioRoomJoinCard`) with a Join button; tapping it
launches `AudioRoomActivity`, which owns the MoQ session, the
broadcaster, presence (kind 10312), hand-raise, and the foreground
service for screen-locked playback.

Why: the previous design tied the audio session to the chat-room
screen's Composable lifecycle, which (a) made "navigate away keeps
audio playing" require a process-level workaround, (b) tied
hand-raise + presence to a UI screen even when the user wasn't audio-
connected, (c) had no clear home for PIP. A dedicated Activity
mirrors the existing CallActivity pattern: clear lifecycle, Activity
finish() = leave room, PIP = keep speakers visible while doing other
things.

Files split into focused units (the original single-file design hit
generation limits):
- `AudioRoomActivity.kt` (~200 lines): Activity class, PIP entry on
  onUserLeaveHint, PIP RemoteAction wiring (mute / leave) with a
  BroadcastReceiver that signals a shared toggle into the composable.
  Manifest registers it with `supportsPictureInPicture=true`,
  `launchMode=singleTask`, `resizeableActivity=true`.
- `AudioRoomActivityContent.kt`: top-level composable. Loads the
  addressable note, constructs the VM via factory, auto-connects on
  entry, runs the presence loop (with mic-mute reflected in the kind
  10312 event), drives the foreground service, flips between
  full-screen and PIP layouts.
- `AudioRoomFullScreen.kt`: full-screen layout — title, summary,
  on-stage / audience rows with active-speaker rings, ConnectionRow,
  TalkRow (RECORD_AUDIO permission flow inside), hand-raise, Leave
  button.
- `AudioRoomPipScreen.kt`: compact PIP layout — up to 4 on-stage
  avatars with speaking ring + room title.
- `AudioRoomCommon.kt`: shared StagePeopleRow + connectingLabel.
- `AudioRoomJoinCard.kt`: lobby card in ChannelView. Title + summary
  + Join button. Hidden when the event has no `service` tag.
- `AudioRoomBridge.kt`: process-level singleton that hands the active
  AccountViewModel to the standalone Activity (mirrors
  CallSessionBridge).
- `AudioRoomViewModelFactory.kt`: extracted from the deleted
  AudioRoomStage; binds the Android audio actuals (OkHttp, QUIC,
  MediaCodec, AudioTrack, AudioRecord).

Deleted: `AudioRoomStage.kt` (replaced by the lobby card + the
Activity).

ChannelView wiring changed from `AudioRoomStage(channel, …)` to
`AudioRoomJoinCard(channel, …)` — one-line swap.

Strings: 2 new (`audio_room_join`, `audio_room_leave`).

Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:amethyst:compilePlayDebugKotlin` all green.
2026-04-26 03:59:10 +00:00
Claude 8e60a79eaf perf(video): reduce jitter and per-recomposition work in note video pipeline
Tightens the hot path that runs whenever a video appears inside a note
in the feed (RichText -> ZoomableContentView -> VideoView).

- VideoView: resolve aspect ratio once per (uri, dim), and prime
  MediaAspectRatioCache from the imeta dim tag so repeat appearances
  (PiP, dialog, list re-enter) don't have to wait for ExoPlayer's
  onVideoSizeChanged before reserving layout space.
- VideoView: key the manual "tap to show" toggle on videoUri so a
  recycled feed slot doesn't inherit stale state from the previous video.
- VideoViewInner: hoist proxyPortForVideo() into a remember(videoUri) —
  the result was being recomputed every recomposition only to be dropped
  by GetMediaItem's URI-keyed remember.
- RenderVideoPlayer: stop holding container size in compose state.
  The size is only read in onDoubleTap, so a non-state IntArray holder
  removes a recomposition of the whole player tree on every layout pass.
  Also memoize isLiveStreaming() so the .m3u8 substring scan doesn't run
  on every recomposition.
- RenderTopButtons: same isLiveStreaming() memoization.
- GetVideoController: switch the remaining non-lambda Log.d call to the
  lambda overload so the message string isn't formatted when the log
  level is filtered out.
2026-04-26 03:46:49 +00:00