Commit Graph

11350 Commits

Author SHA1 Message Date
Vitor Pamplona e9bb155472 spotless 2026-04-15 16:01:14 -04:00
Vitor Pamplona 9881c45701 Merge pull request #2413 from vitorpamplona/claude/fix-multi-device-call-ringing-Jhodl
Add multi-device call handling and architecture documentation
2026-04-15 16:00:33 -04:00
Claude 421b31f84f docs(call): add ARCHITECTURE.md summarizing the WebRTC call pipeline
Maps the full call flow — NIP-AC event kinds, module layout across
quartz/commons/amethyst, the CallManager state machine, incoming and
outgoing signaling pipelines, group-call invited-vs-watchdog timer
semantics, multi-device "answered elsewhere" handling, and the
load-bearing invariants (stateMutex discipline, self-pubkey guards,
publish-outside-the-lock pattern).

Lives next to CallManager.kt so the next contributor finds it without
an hour of grepping.

https://claude.ai/code/session_01QVUnhr79hYqQuXFiEb8puk
2026-04-15 19:49:50 +00:00
Vitor Pamplona 091e07324c Merge pull request #2412 from vitorpamplona/claude/implement-nip-82-dr2Ko
Add NIP-82 Software Apps event types and tag support
2026-04-15 15:43:50 -04:00
Claude 129eb114a7 feat(quartz): add experimental NIP-82 software applications
Implements draft NIP-82 in the experimental package, following the
NIP-88 polls file structure. Adds three new events:

- SoftwareApplicationEvent (kind 32267): parameterized replaceable event
  describing a software application, keyed by reverse-domain `d` tag
- SoftwareReleaseEvent (kind 30063): parameterized replaceable event
  grouping assets under a versioned release channel
- SoftwareAssetEvent (kind 3063): regular event binding an installable
  artifact to its sha256, mime type, platform and version metadata

SoftwareApplicationEvent (32267) and SoftwareAssetEvent (3063) are
registered in EventFactory. SoftwareReleaseEvent (30063) is intentionally
left unregistered because that kind already maps to NIP-51
ReleaseArtifactSetEvent; callers can instantiate it directly.
2026-04-15 19:41:51 +00:00
Claude 822718e687 fix(call): stop ringing on sibling devices without disturbing the one that answered
When the user is logged in on two devices and receives a call, both
ring.  When one picks up, the other should stop ringing immediately —
but crucially, the device that answered must not lose its WebRTC
connection when the silenced device reacts.

The "answered elsewhere" state machine (onCallAnswered at the self-
pubkey branch) was already in place and covered by a unit test, but the
signal it relies on was never actually published: acceptCall wrapped the
CallAnswerEvent only for `groupMembers - signer.pubKey` so in a 1:1 call
it only reached the caller.  Sibling devices subscribed to gift wraps
for their own pubkey never saw it and kept ringing until the 60 s local
timeout.

Fixes:

- acceptCall / rejectCall publish an extra gift wrap of the *same*
  signed answer/reject event addressed to `signer.pubKey`, so sibling
  devices in IncomingCall observe the echo and transition to
  Ended(ANSWERED_ELSEWHERE / REJECTED) locally.  Neither path publishes
  any further signaling, so the device that picked up is never
  disturbed.

- onCallRejected: add a self-pubkey early-return mirroring
  onCallAnswered.  Without it, a self-reject echoing back to a device
  already in Connecting/Connected hit the peer-rejection branches,
  firing onPeerLeft(signer.pubKey) — which would tell CallController to
  dispose its own PeerSession and tear down the live audio.

- onSignalingEvent: record self-answer call-ids in completedCallIds
  alongside hangups and rejects, so an out-of-order relay replay
  delivering the self-answer before the original offer does not make a
  sibling device start ringing for a call it already knows was answered.

Adds multi-device tests covering: the new self-addressed wraps in
acceptCall/rejectCall, the end-to-end "two phones, one answers"
scenario, the Connected / Connecting self-reject echo guards, and the
out-of-order offer-after-self-answer replay case.

https://claude.ai/code/session_01QVUnhr79hYqQuXFiEb8puk
2026-04-15 19:35:21 +00:00
Vitor Pamplona fd9d6ed126 Merge pull request #2411 from vitorpamplona/claude/fix-group-persistence-DQkG8
fix(marmot): use GCMParameterSpec for decrypt + stop auto-deleting gr…
2026-04-15 15:29:21 -04:00
Claude c04d3d0bf0 fix(marmot): use GCMParameterSpec for decrypt + stop auto-deleting groups on restore failure
Logs from the real device showed that with the previous fixes save and
listGroups now work correctly (878 → 1162 bytes written, file found
after restart), but decrypt was throwing:

    InvalidAlgorithmParameterException: Only GCMParameterSpec supported
        at AndroidKeyStoreAuthenticatedAESCipherSpi$GCM.initAlgorithmSpecificParameters

AndroidKeyStore's authenticated AES/GCM cipher rejects plain
IvParameterSpec and requires an explicit GCMParameterSpec with the
auth tag length. Switched decrypt() to use GCMParameterSpec(128, iv).

While chasing this I also noticed that MlsGroupManager.restoreAll
was **deleting** the stored group on any exception from load(), on
the assumption that it must be corrupted. This turned a transient
decrypt bug into permanent data loss — the user's group file was
wiped during the first broken restart. Change the catch block to
log and skip instead of delete, so after a fix the next restart
can still recover the data.

https://claude.ai/code/session_014EKS8JBwSpap34aM6FnYLm
2026-04-15 19:27:36 +00:00
Vitor Pamplona 61add7f1aa Merge pull request #2410 from vitorpamplona/claude/fix-group-persistence-DQkG8
fix(marmot): switch KeyStoreEncryption transformation to AES/GCM/NoPa…
2026-04-15 15:19:36 -04:00
Claude 4d5861e581 fix(marmot): switch KeyStoreEncryption transformation to AES/GCM/NoPadding
Logs from the real device showed that the AndroidMlsGroupStateStore
constructor was throwing NoSuchAlgorithmException for
"AES/GCM/PKCS7Padding" at Cipher.getInstance, which caused
AccountCacheState to silently fall back to InMemoryMlsGroupStateStore
for every account. That's why Marmot groups vanished on every restart.

GCM is an authenticated encryption mode and must not use a block
padding scheme — the correct transformation is AES/GCM/NoPadding.
PKCS7Padding was always wrong; no provider on this device accepts it
for GCM. Switch PADDING to ENCRYPTION_PADDING_NONE.

This class is currently only wired to AndroidMlsGroupStateStore
(AccountSecretsEncryptedStores is defined but not referenced anywhere
in the runtime code path — only in docs/secure-key-storage-migration.md),
so there is no on-disk data that was previously encrypted with the
broken transformation to worry about.

https://claude.ai/code/session_014EKS8JBwSpap34aM6FnYLm
2026-04-15 19:15:52 +00:00
Vitor Pamplona c63466b3eb Merge pull request #2409 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-15 15:11:31 -04:00
Crowdin Bot 89d36114b1 New Crowdin translations by GitHub Action 2026-04-15 19:03:34 +00:00
Vitor Pamplona dffa8a5041 Merge pull request #2406 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-15 15:01:56 -04:00
Vitor Pamplona 17d138d297 Merge pull request #2408 from vitorpamplona/claude/fix-group-persistence-DQkG8
Add comprehensive logging to MLS group persistence and recovery
2026-04-15 15:01:48 -04:00
Vitor Pamplona 3c5b873952 Merge pull request #2407 from vitorpamplona/claude/fix-call-rejection-audio-Ty3MU
Fix ringtone/notification stuck after rejecting consecutive calls
2026-04-15 15:01:01 -04:00
Claude 7f2d44df7e fix(marmot): add diagnostic logs to group persistence path + use explicit AndroidKeyStore provider
User-reported issue: creating a Marmot group works in a single session,
but the group is gone after app restart. The store code path survives
the persistGroup save on creation, but something between save and
restore silently loses the state.

Changes:

- KeyStoreEncryption: pass "AndroidKeyStore" explicitly to
  KeyGenerator.getInstance so generator.init(KeyGenParameterSpec) is
  guaranteed to land on the AndroidKeyStore provider (the default
  provider selection is not guaranteed to choose it). Also wrap
  encrypt/decrypt in try/catch so the real exception is logged
  instead of bubbling up as a generic failure.

- AndroidMlsGroupStateStore: log rootDir at construction, and log
  every save/load/listGroups/delete with file paths, byte counts,
  and whether the target file actually exists after writing. Any
  thrown exception is now logged with full stack trace before
  rethrowing.

- MlsGroupManager: log create / persist / restoreAll with group ids,
  byte counts, and in-memory group count so we can see exactly which
  step drops state. The existing "corrupted state, delete" branch now
  logs at ERROR with a clearer marker so it's easy to spot in the logs.

- MarmotManager + AccountCacheState: log which MLS store implementation
  is chosen per account (Android vs InMemory fallback) and the root
  filesDir path, plus restoreAll() begin/end with restored group ids.

These logs should make it obvious whether:
  1. persistence is silently falling back to InMemoryMlsGroupStateStore,
  2. save is actually writing the file to disk,
  3. listGroups is finding the directory on restart,
  4. decrypt is throwing and the restoreAll catch block is wiping
     the "corrupted" state.

https://claude.ai/code/session_014EKS8JBwSpap34aM6FnYLm
2026-04-15 18:24:58 +00:00
Claude 7db94880d7 fix: stop ringtone and call notification when rejecting consecutive calls
The cleanedUp AtomicBoolean guard in CallController was only re-armed in
initiateGroupCall() and acceptIncomingCall(). For incoming calls the user
rejected (or that the remote party hung up before being accepted), the
flag stayed true after the first cleanup, so on the next incoming call
cleanup() became a no-op — leaving the ringtone playing and the
notification stuck on screen until the app was force-killed.

Three layers of defense:

1. Re-arm cleanedUp in the state collector when entering IncomingCall or
   Offering, so cleanup() always runs for new call sessions regardless of
   how the previous one ended.

2. Move the (idempotent) ringtone/ringback/notification stops in
   cleanup() out of the guarded block so they run unconditionally, even
   if the heavy teardown is already complete.

3. In CallActivity.onDestroy(), explicitly stop the ringtone, ringback
   tone, and call notification after controller.cleanup() so their
   lifecycle is hard-tied to the Activity — when the call UI goes away
   for any reason, the audio and notification go away too.
2026-04-15 18:18:56 +00:00
Crowdin Bot 26934959d7 New Crowdin translations by GitHub Action 2026-04-15 17:27:34 +00:00
Vitor Pamplona 558eb984a0 Merge pull request #2405 from vitorpamplona/claude/fix-group-call-timeout-XxQxs
fix: end call when the last connected peer hangs up
2026-04-15 13:25:43 -04:00
Claude 3a5c118d35 fix: end call when the last connected peer hangs up
Scenario: Alice calls {bob, carol} as a group. Bob answers, Bob and
Alice are talking, Carol never joins. Before Carol's 30 s watchdog
fires, Alice hangs up. Bob's state had peerPubKeys={alice} and
pendingPeerPubKeys={carol}. The old onPeerHangup handler only ended
the call when BOTH sets became empty, so Bob stayed in Connected
alone staring at a black screen until Carol's watchdog finally
dropped her — and even then the state machine didn't terminate
because "no peers, no pending" wasn't true at the exact same step.

Change the termination rule in onPeerHangup (both Connected and
Connecting states) to end the call as soon as connectedRemaining
becomes empty, regardless of how many pending peers are still
tracked. Without a single connected peer the call has no one to
talk to and can't meaningfully continue.

Also publish a CallHangup to any peers in the pending set that WE
personally invited (peersInvitedByUs ∩ pendingPeerPubKeys) so their
devices stop ringing. Peers we did not invite (e.g. group members
the caller originally included) are the caller's responsibility —
the caller's own group hangup already reaches them — so we leave
them alone.

Adds three regression tests covering:
- Connected state: caller hangs up while one pending member remains
- Connecting state: same, before onPeerConnected fires
- Callee had invited someone mid-call: call ends AND Bob notifies
  his invitee to stop ringing
2026-04-15 16:30:35 +00:00
Vitor Pamplona db18954d91 Fixes key creation for groups. 2026-04-15 12:04:38 -04:00
Vitor Pamplona e9996f474b Merge pull request #2404 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-15 11:12:27 -04:00
Crowdin Bot a361e4a331 New Crowdin translations by GitHub Action 2026-04-15 15:10:48 +00:00
Vitor Pamplona 4fefa40d1f Merge pull request #2401 from davotoula/hls-video-integration
Share HLS Video via NIP-71 + LightCompressor-enhanced 2.2.0
2026-04-15 11:09:04 -04:00
Vitor Pamplona ca95c62d51 Merge pull request #2402 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-15 11:08:55 -04:00
Vitor Pamplona 1d5d210bc3 Merge branch 'main' into hls-video-integration 2026-04-15 11:08:41 -04:00
Vitor Pamplona 3914bba3dd Merge pull request #2403 from vitorpamplona/claude/fix-keypair-generator-error-RLGir
Fix Ed25519 provider selection to avoid AndroidKeyStore on Android
2026-04-15 11:08:07 -04:00
Crowdin Bot dc93a3e666 New Crowdin translations by GitHub Action 2026-04-15 14:53:46 +00:00
Vitor Pamplona 99c5f08eb3 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-04-15 10:51:18 -04:00
Vitor Pamplona 5f4149d3dc Uses our api 2026-04-15 10:43:36 -04:00
Vitor Pamplona 31ad5839d7 Merge pull request #2400 from vitorpamplona/claude/fix-group-call-timeout-XxQxs
Implement per-peer watchdog timers for group call members
2026-04-15 10:40:52 -04:00
Claude f670724cd8 fix: drop unresponsive group-call peers on callee side after 30 s
When a caller invites a group and one of the members never answers,
the caller already drops the unresponsive peer after 30 s (via the
per-peer watchdog in CallManager). The hangup the caller publishes is
addressed only to the unresponsive peer, so the OTHER participants
never learn that the peer is out and keep waiting for them forever.

Mirror the caller's 30 s budget on the callee side. In acceptCall,
split the group members into:

- peerPubKeys: the caller (they sent us the offer) plus any peers
  whose answer we observed while still ringing. These are confirmed
  to be in the call.
- pendingPeerPubKeys: everyone else we haven't heard from yet. Each
  gets a local watchdog timer; if we never observe them (via a mesh
  answer or a mid-call offer) within 30 s they are silently dropped
  from our local state.

Because the callee is not the peer's inviter, handlePeerTimeout must
NOT publish a CallHangup in this path — terminating the invitee's
ringing is the caller's responsibility. Track "who we invited" in a
peersInvitedByUs set so the caller-side path (beginOffering,
initiateCall, invitePeer) still publishes the hangup, while the new
callee-side watchdog path drops the peer silently.

Also move a pending peer into peerPubKeys and cancel their watchdog
when they send us a mid-call mesh offer — the offer is proof they're
in the call, so we should not wait further.

Adds three new tests (callee watchdog drops pending peer, mid-call
offer transitions pending peer, watchdog is cancelled on answer from
pending peer) and updates two existing tests for the new Connecting
state shape when a group call is accepted.
2026-04-15 14:36:02 +00:00
Claude 78a1c3cb28 fix: avoid AndroidKeyStore provider for Ed25519 in Marmot MLS
On Android, KeyPairGenerator.getInstance("Ed25519") could resolve to
AndroidKeyStoreKeyPairGeneratorSpi, which rejects NamedParameterSpec and
requires KeyGenParameterSpec, crashing group creation with
IllegalArgumentException. Explicitly select a non-AndroidKeyStore
provider (Conscrypt on Android, SunEC on JVM) for KeyPairGenerator,
KeyFactory, and Signature, and drop the unnecessary initialize() call
since Ed25519 is fully specified by its algorithm name.
2026-04-15 14:25:38 +00:00
Vitor Pamplona 78d794aaab Merge pull request #2399 from vitorpamplona/claude/improve-zoom-animation-5csfK
Add shared element transition animation to image/video dialogs
2026-04-15 09:38:32 -04:00
Claude 32ced1326d fix: restore enter animation by awaiting first valid image bounds
The previous two-LaunchedEffect handshake (one to flip boundsReady,
another to animate on the new key) was unreliable — the enter
animation frequently never ran and the dialog snapped straight to
fullscreen.

Hoist imageBounds up to ZoomableImageDialog alongside progress and
use a single LaunchedEffect(Unit) that awaits the first valid bounds
via snapshotFlow before calling progress.animateTo(1f). Bounds are
always accepted on first capture so the flow can emit; subsequent
updates are still blocked while progress.isRunning so a mid-flight
layout change can't cause a hiccup. After the animation settles,
updates flow through again so the exit animation targets the current
on-screen image.
2026-04-15 13:34:18 +00:00
davotoula fee6607c38 chore(hls): bump lightcompressor-enhanced to released 2.2.0
Moves off the 2.1.1-hls-SNAPSHOT mavenLocal iteration onto the
released JitPack artifact. API surface is identical to the final
SNAPSHOT Amethyst was already running against after the library
code review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:33:48 +02:00
davotoula 79d28d9e4d code review fixes 2026-04-15 15:33:48 +02:00
davotoula 0a649dbea6 fix(hls): stick the upload counter + flip past tense between uploads
Two related UX issues on the HLS publish progress screen:

1. The Uploading PhaseRow's label used a fallback of
   `stringResource(hls_state_uploading_format, 0, 0)` whenever state
   wasn't Uploading, so the row flickered back to "Uploading 0 of 0"
   during the Transcoding phase between rendition uploads. The counter
   appeared to reset mid-flow even though every upload was succeeding.

2. With the counter now persisted, it still read "Uploading 2 of 5"
   while we were actually transcoding the next rendition — the count
   was right but the verb tense implied an upload was in flight when
   none was.

Persist lastDone + lastTotal in the composable via remember so the
counter reads monotonically across state transitions, and split the
label into two semantic variants:

- "Uploading %s (%d of %d)" — present tense, only when an upload is
  actually in flight (state is HlsPublishState.Uploading). The %d is
  the pre-incremented in-flight index.
- "Uploaded %d of %d" — past tense, used both in the gap between
  uploads (transcoding next rendition) and in the done/checkmark
  state. The %d is the last observed in-flight index, which after the
  lambda returns corresponds to the count that has actually finished.
- "Upload" — only the pre-first-upload idle state.

Also add a progressFraction that falls back to lastDone/lastTotal so
the progress bar under the row sticks too, matching the label.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:33:48 +02:00
davotoula 83d0dd9067 refactor(hls): adopt HlsUploadResult<T>.uploads, drop side-channel map
The library's HlsUploadHelper.run is now generic: the lambda returns
HlsUploaded<T>(url, metadata) and the result surfaces every upload as
HlsUploadResult<T>.uploads keyed by the same suggestedFilename the
library handed the lambda. Amethyst threads MediaUploadResult through
as T, so the per-rendition sha256 / size needed for NIP-71 imeta tags
comes out of the helper's own return value instead of a
hand-maintained side-channel MutableMap the orchestrator kept in
parallel.

- HlsPublishOrchestrator.runUpload closure signature:
    suspend (HlsConfig, HlsListener, suspend (File, String) -> String)
        -> HlsUploadResult
  becomes
    suspend (HlsConfig, HlsListener, suspend (File, String) -> HlsUploaded<MediaUploadResult>)
        -> HlsUploadResult<MediaUploadResult>
- delete `segmentUploads = mutableMapOf<String, MediaUploadResult>()`
  inside publish(); read directly from uploadResult.uploads instead
- HlsVideoPublishInput.segmentUploads: Map<String, MediaUploadResult>
  becomes uploads: Map<String, HlsUploaded<MediaUploadResult>>; event
  builder reads playlistUpload.url and combinedMetadata?.sha256/size
  via .metadata
- HlsPublishOrchestratorFactory's HlsUploadHelper.run call uses the
  new generic form with T = MediaUploadResult
- tests: fakeRunUpload now synthesizes the uploads map in a
  LinkedHashMap (matching the library's iteration-order contract) and
  returns HlsUploadResult<MediaUploadResult>

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:33:48 +02:00
davotoula 21670b2834 fix(hls): pre-increment upload counter so it ticks through N of M
Previously the lambda set HlsPublishState.Uploading(done=uploadsDone)
before running the upload and incremented after. So while upload #1 was
actually in flight the state said "0 / 5 done", and StateFlow conflation
ate the post-upload increment before the UI could paint it — the counter
appeared stalled until the next rendition's onProgress flipped state
back to Transcoding.

Pre-incrementing means `done` now represents "working on item N of M"
rather than "N already finished, N+1 silently in flight", so the first
upload displays as "1 of 5" and each subsequent upload ticks to 2, 3,
4, 5 visibly. The earlier "done" wording existed to disambiguate 0/N
from a stalled bar; with pre-increment we never show 0/N, so the string
drops "done" and reads as the cleaner "Uploading X of Y".

Confirmed via logcat on device: R1 sinkFinish=5669ms, R2 sinkFinish=6343ms
— the "phantom upload" between R1 encoding and R2 encoding really was
the R1 combined upload; the counter just wasn't reflecting it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:33:48 +02:00
davotoula c62f7c8271 fix(hls): pop publish screen when opening draft-note composer
Previously the "Draft note" button on the HLS success screen pushed the
composer on top of the publish screen. When the composer popped itself
after posting, the user landed back on the now-Idle HLS publish screen
with the form still populated — confusing "I'm back where I started"
UX. Use popUpTo so the publish screen is removed from the back stack as
the composer opens; posting or backing out now drops the user on the
screen they came from.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:33:48 +02:00
davotoula 90235e91f2 refactor(hls): upgrade to lightcompressor-enhanced 2.1.1-hls-SNAPSHOT
Collapses Amethyst's hand-rolled HLS orchestration onto the library's
HlsUploadHelper.run. The library now ships everything the prior session
had to reimplement: per-rendition width/height/codec metadata on the
onRenditionComplete callback, a public PlaylistRewriter, canonical
HlsContentTypes constants, and the transcode -> upload -> rewrite loop
itself.

- bump libs.versions.toml to 2.1.1-hls-SNAPSHOT
- delete HlsUploadPipeline, HlsBundle, HlsTranscoder, HlsTranscodingSession,
  HlsPlaylistRewriter and their tests; HlsUploadHelper.run + the library
  rewriter cover everything they did
- rewrite HlsVideoEventBuilder to consume HlsRenditionSummary width/height
  directly; drops the master-playlist streamInfRegex entirely
- HlsPublishOrchestrator now wraps HlsUploadHelper.run: a SimpleHlsListener
  drives Transcoding progress while the uploader lambda captures each
  MediaUploadResult in a side-channel map keyed by the library's
  suggestedFilename, so per-rendition sha256/size still flow into the
  NIP-71 imeta tags
- extract HlsBlobUploader into its own file (was inline in the deleted
  HlsUploadPipeline.kt)

Net delta on HLS code: 3194 -> 2182 lines (-1012, ~32%).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:33:48 +02:00
davotoula d58dd4bcce fix(hls): lay rendition checkboxes out horizontally via FlowRow
Five full-width rows eating half a screen was too much real estate for
a five-item toggle. Replace the fillMaxWidth stack with a FlowRow where
each item is a compact checkbox + short label ("360p", "540p", …),
wrapping to the next line on narrow screens.

The bitrate subline per rendition was cut — the values are public
library defaults anyway (360/540/720/1080/4K ladder) and the secondary
text was the biggest contributor to the vertical bloat. Above-source
rungs remain disabled; the grey-out on the checkbox + label is still
visible in the new layout.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-15 15:33:48 +02:00
davotoula 5a9298c603 fix(hls): add "done" to the upload counter so 0 / N reads less like a stall
The bare "0 / 5" read like the pipeline was stuck before the first
upload completed. Appending "done" makes the count read as a
completion tally — "0 / 5 done" clearly says "nothing finished yet"
rather than "0 steps remaining" — and matches the label's intent.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-15 15:33:48 +02:00
davotoula 11c6a9fea1 feat(hls): show which file is currently uploading during the upload phase
Users saw "Uploading 0/3" sit motionless for many seconds while the
first large rendition crossed the wire, with no indication that
anything was in flight. The counter only increments AFTER each upload
completes, so the 0/N state is technically correct but reads like a
stall.

Pipeline progress callback signature is now
(done, total, currentLabel), emitted BEFORE each upload starts:
- "360p video" (done=0) -> upload
- "360p playlist" (done=1) -> upload
- "540p video" (done=2) -> upload
- ...
- "master playlist" (done=4) -> upload
- "" (done=5) -> trailing completion tick

HlsPublishState.Uploading gains a currentLabel field. The progress row
in NewHlsVideoScreen picks up a new string "Uploading %s (%d / %d)"
when the label is non-blank, falling back to the plain counter form
for the initial Transcoding->Uploading transition.

Test updated to assert the full six-emit sequence for a two-rendition
bundle including the blank-label terminal emit.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-15 15:33:48 +02:00
davotoula 610528cfcd refactor(hls): trust the upload server URL verbatim, drop withExtensionHint
Policy shift agreed with the server-side collaborator: the URL the
NIP-96 / Blossom server returns is already the optimal form — clean
Content-Type, correct cache keys, range-friendly — and the client
should not second-guess it. Stripping extensions, appending hints or
rewriting to bare sha256 all add round trips and cache misses for no
real win now that the WordPress plugin returns clean <hash>.m3u8 URLs.

Concretely:
- HlsUploadPipeline drops the withExtensionHint helper entirely. Every
  combinedUrl / mediaPlaylistUrl / masterUrl flows straight from the
  uploader's MediaUploadResult.url into the playlist rewrite and the
  HlsUploadResult.
- A small kdoc note at the top of the pipeline documents the policy
  ("if a server returns an unplayable URL, the fix is server-side").

Pipeline tests pruned:
- appendsMp4HintToBareCombinedUrlInMediaPlaylist — removed, was testing
  the appender that no longer exists.
- appendsM3u8HintToBarePlaylistUrlInMasterPlaylist — removed, same.
- trailingDotUrlsAreSanitisedIntoCleanExtension — removed, the Nip96
  fallback-extension map keeps the server from returning trailing-dot
  URLs in the first place.
- doesNotDoubleAppendExtensionWhenAlreadyPresent — removed.

Kept and reframed:
- serverUrlsAlreadyWithExtensionPassThroughUntouched — the canonical
  contract: server returns clean .m3u8 / .mp4, pipeline forwards
  verbatim.
- bareServerUrlsPassThroughVerbatim (new) — explicitly documents that
  even a bare-hash URL flows through unchanged; the pipeline does not
  try to make it playable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-15 15:33:48 +02:00
davotoula a4787c5fdc feat(hls): checkboxes to pick which renditions to upload
Users hit a server upload failure and asked for the ability to skip the
biggest renditions. The LightCompressor HlsLadder API already supports
filtering — this commit wires that through the full stack:

- HlsTranscoder.transcode() now accepts an HlsLadder parameter
  defaulting to HlsLadder.default(), and passes it into HlsConfig.
- HlsPublishRequest gains a ladder field.
- HlsPublishOrchestrator.runTranscode callback now takes the ladder as
  a fourth parameter; the orchestrator forwards request.ladder into it.
- The production factory captures the ladder on each publish.
- NewHlsVideoViewModel tracks selectedRenditionLabels as a mutable Set
  defaulting to all five default rungs. publish() builds an HlsLadder
  from that set by filtering HlsLadder.default().renditions and
  refuses to publish when the set is empty.
- NewHlsVideoScreen replaces the read-only renditions preview with a
  RenditionsCheckboxes composable. Each default rung renders as a
  Checkbox + label + bitrate; rungs above the detected source short
  side are disabled with an "above source — will be skipped" subline
  so the user cannot accidentally select a rendition the library
  would drop anyway.
- Publish button gates on selectedRenditionLabels.isNotEmpty() so an
  empty selection never fires the pipeline.

Tests updated for the new runTranscode signature.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-15 15:33:48 +02:00
davotoula 453bdab202 refactor(hls): replace auto kind-1 cross-post with editable draft handoff
The auto-publish behaviour from the previous commit silently sent a
kind-1 note the user could not edit before it hit the relays. Replace
it with a handoff into Amethyst's existing NewShortNote composer
pre-filled with the title, description and master playlist URL so the
author can tweak the text, add hashtags/mentions, and send when ready.

Changes:
- HlsPublishOrchestrator drops the signAndPublishNote callback and
  stops publishing any kind-1 itself. HlsPublishState.Success drops
  the noteEventId field.
- HlsPublishRequest drops crossPostAsNote (the orchestrator no longer
  cares about the toggle).
- NewHlsVideoViewModel renames crossPostAsNote -> draftNoteAfterUpload
  and the screen renames the switch to "Draft note after upload".
- SuccessBody now reads vm.draftNoteAfterUpload: when on, it offers a
  "Draft note" primary button that navigates to Route.NewShortNote
  with the message parameter filled via a small buildDraftNoteText
  helper (title + description + masterUrl, blanks collapsed). The
  existing short-note composer accepts message as its initial text.
  When off, the button reverts to "Done".
- Production factory wiring drops the TextNoteEvent import + closure.
- Orchestrator tests drop the two cross-post cases; a single
  signAndPublish callback is now enough.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-15 15:33:47 +02:00
davotoula 2dab90fc2e fix(video-quality): label by short side so portrait videos show 360p/540p etc
VideoQualityButton and VideoQualityChoices were labelling each
rendition by format.height. That matches the streaming convention
"360p = 360 pixel short side" only for landscape content. For a
portrait upload (9:16) the renditions encode as 360x640, 540x960,
720x1280, 1080x1920, 2160x3840 — so format.height is the long side,
and the picker rendered "640p / 960p / 1280p / 1920p / 3840p" instead
of the expected "360p / 540p / 720p / 1080p / 4K".

Switch to minOf(format.width, format.height) (the short side) for both
the ladder rung labels and the currently-playing indicator. Rename
QualityChoice.height -> QualityChoice.shortSide and
getCurrentPlayingHeight() -> getCurrentPlayingShortSide() so the
fields match the thing they now represent, and add a one-line comment
explaining the convention.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-15 15:33:47 +02:00
davotoula 295e208f36 test(hls): lock in server-returns-clean-url pass-through
Server-side fix is in: the NIP-96 WordPress plugin now returns clean
"<hash>.m3u8" / "<hash>.mp4" URLs instead of echoing our upload filename
with a trailing bare dot. Pin down that the pipeline passes those URLs
through untouched — no second ".m3u8" appended on top, no regex hiccup,
nothing.

The existing withExtensionHint logic already handles this correctly
(endsWith check with ignoreCase) but the test makes the intent explicit
so a future refactor cannot silently re-introduce the double-extension
bug.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-15 15:33:47 +02:00