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
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.
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
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.
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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Adds an optional kind-1 TextNoteEvent companion to every HLS publish so
the upload shows up in the home feed (where most people scroll)
alongside the NIP-71 VideoHorizontalEvent/VideoVerticalEvent in the
dedicated video tab. The companion note's content is the title, the
description and the master playlist URL joined with blank lines so
Amethyst's rich-text parser renders the inline video player and
non-NIP-71 clients still see a clickable master.m3u8 link.
Orchestrator picks up a new suspend signAndPublishNote callback that
takes the note content and returns the signed event id. The note is
only signed after the NIP-71 publish succeeds, so a failed video
publish never produces an orphaned note. HlsPublishState.Success now
carries the nullable noteEventId, and the success screen offers a
"View note" primary button that navigates to Route.Note(noteEventId)
when set; otherwise it falls back to the existing "Done" button.
The form switch defaults to on and sits just below the content-warning
row. Turning it off leaves the noteEventId null in the final Success
state.
Production wiring uses TextNoteEvent.build(content) +
account.signer.sign + account.sendAutomatic, matching the existing
short-note publish path.
Two new orchestrator tests lock in the cross-post behaviour: one that
captures the note content and asserts it contains the title,
description and master URL, and one that verifies crossPostAsNote=false
never invokes the note callback and leaves noteEventId null.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Root cause: Android's MimeTypeMap does not know
application/vnd.apple.mpegurl, so Nip96Uploader.upload() resolved the
extension to "" and sent the multipart filename as "abc." (trailing
dot). The NIP-96 server then echoed the upload name into its returned
URL, which arrived at the pipeline ending in ".". The pipeline's
withExtensionHint helper appended ".m3u8" on top of that, producing
"..m3u8" — which the subsequent playback GET 404s against because the
server stored the file at the single-dot path.
Two fixes, both needed:
1. Nip96Uploader.upload(InputStream, ...) now falls back to a small
static MIME->extension table when MimeTypeMap returns null, covering
the HLS playlist types and video/mp4 / fMP4 segment types. The file
is uploaded with a real ".m3u8" / ".mp4" extension, so the server
never has to invent one.
2. HlsUploadPipeline.withExtensionHint now trims trailing dots and
collapses any existing "..ext" sequences before checking whether to
append, so a server that still echoes a single trailing dot cannot
produce unreachable URLs.
Regression test locks in that a fake uploader returning a
"https://server/bare-1." URL yields a single-dot ".m3u8" in the final
upload result.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The shared okHttpClientForUploads ships with a 30 s read and write
timeout on Wi-Fi. A 9 MB+ rendition where the server does synchronous
hashing / virus scanning easily takes longer than 30 s to return 200,
and OkHttp fires readTimeout while the request is still in flight.
The pipeline throws, the orchestrator moves to Failure, and the master
playlist upload never happens — matching the on-wire observation that
all 10 rendition files reach the server but the master does not.
HLS gets its own dedicated OkHttpClient derived from the shared upload
client: writeTimeout and readTimeout are set to 0 (disabled) so a slow
rendition can trickle through and a slow server can take as long as it
needs to respond. A generous 15 minute per-call timeout remains as a
hard cap so a silently dead connection eventually errors out.
Fixes the "server processed the upload 74 seconds after the client
marked it failed" race the upload report flagged as a classic wire-won
case.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The server dropdown on the HLS Upload screen was hardcoded to
DEFAULT_MEDIA_SERVERS, so any Blossom server the user configured in
Settings -> Media Servers did not appear. Wire it to
account.blossomServers.hostNameFlow (same StateFlow the existing
AllMediaBody settings screen consumes), which yields the signed
BlossomServersEvent normalized into List<ServerName> and falls back to
DEFAULT_MEDIA_SERVERS when the user has none configured.
Initial selectedServer prefers account.settings.defaultFileServer when
it is still in the list, otherwise the first available server. A
collect job re-syncs the list if the user adds/removes servers while
the screen is open.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
NewHlsVideoScreen crashed on open with IllegalStateException("load()
must be called first") because NewHlsVideoBody reads vm.state during
the first composition, but LaunchedEffect runs vm.load(account, context)
only AFTER that first composition completes — classic pre-load race.
Moves the MutableStateFlow<HlsPublishState> out of the orchestrator and
into the ViewModel, so vm.state is safe to read from composition start.
The orchestrator now receives the flow as a constructor param and
writes into it as before — same semantics, no new race windows. Tests
and the production factory are updated to pass the shared flow through.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Renames the share_hls_video label to "HLS Upload" so the drawer row
matches the on-device name the user sees everywhere.
- Adds Route.NewHlsVideo as a data object next to the other create
routes in Routes.kt.
- Registers it with composableFromEnd<Route.NewHlsVideo> in
AppNavigation.kt, sliding in from the end the same way the Longs,
Shorts and Pictures creation surfaces do.
- Adds a drawer NavigationRow between Longs and Wallet in ListContent()
with Icons.Outlined.SettingsInputAntenna — reads as "broadcasting /
antenna" and matches the HLS-as-streaming metaphor.
Milestone 5d (final 5.x piece) of the HLS video sharing plan
(2026-04-13). The feature is now reachable end-to-end from the UI.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Four-state screen driven by HlsPublishState:
- Idle (no video): big "Pick a video" card using
ActivityResultContracts.PickVisualMedia(VideoOnly).
- Idle (video picked): selected-file card with duration/resolution/size
probed off-thread via MediaMetadataRetriever, then form fields
(title, multi-line description, content warning switch + reason,
server picker, H.265/H.264 FilterChip toggle that disables H.265
when CompressorUtils.isHevcEncodingSupported() is false, a read-only
renditions preview computed from HlsLadder.default().forSource(), and
the primary "Publish HD video" button).
- In-flight (Transcoding / Uploading / Publishing): three-row phase
view with a check icon for completed phases, a ring for the active
one, and a LinearProgressIndicator underneath the active row; cancel
button at the bottom.
- Success: green check + master playlist URL + Done button that resets
the VM and pops back.
- Failure: error icon + message + Try Again button that resets the VM.
Server picker reuses the existing TextSpinner / TitleExplainer pattern
(same widgets FileServerSelectionRow uses), filtered to exclude NIP-95
since blob-per-event storage blows past relay size limits.
Strings live in values/strings.xml under the "HLS multi-resolution
video sharing" block and reuse the pre-existing content_warning,
file_server, cancel and dismiss entries where appropriate.
Drawer row + route wiring come in milestone 5d.
Milestone 5c of the HLS video sharing plan (2026-04-13).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
HlsBlobUploaderFactory turns a user-chosen ServerName into the simple
file+contentType HlsBlobUploader the pipeline expects by adapting
BlossomUploader or Nip96Uploader. Each adapter reuses the existing
roleBasedHttpClientBuilder.okHttpClientForUploads and the account's
signer helpers (createBlossomUploadAuth / createHTTPAuthorization),
matching how UploadOrchestrator wires them today. NIP-95 is rejected
explicitly — storing each rendition as an event would blow past relay
size limits.
createProductionHlsPublishOrchestrator binds the transcode to
HlsTranscoder, uploads to HlsBlobUploaderFactory, and signAndPublish to
account.signer.sign(...) + account.sendAutomatic(...). The Uri is
captured via a lazy provider so the orchestrator can be constructed at
VM load time before the user picks a video.
NewHlsVideoViewModel.load(account, context) is the new one-call setup
the screen will use; the existing load(account, orchestrator) overload
stays for unit tests.
Milestone 5b of the HLS video sharing plan (2026-04-13).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
feat(hls): build NIP-71 video event templates from upload result
feat(hls): orchestrate per-rendition and master uploads
feat(hls): wrap HlsPreparer into HlsTranscoder + HlsBundle
Verifies HlsPlaylistRewriter handles the default single-file-per-rendition
output from HlsPreparer where every segment reference points to the same
combined fMP4 file and EXT-X-BYTERANGE lines must be preserved unchanged.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Pure rewriter that walks HLS master or media playlists line-by-line and
substitutes each resource reference (segment file, EXT-X-MAP init,
variant media.m3u8) with its uploaded absolute URL. Preserves every
#EXT-X-STREAM-INF, #EXTINF and other directive line verbatim so the
BANDWIDTH/RESOLUTION/CODECS attributes that ExoPlayer's
AdaptiveTrackSelection reads stay intact. Loud failure on missing
entries in the URL map to avoid silent data loss.
Bumps lightcompressor-enhanced to 2.1.0 for the HlsPreparer API that
the following milestones will wrap.
Milestone 1 of the HLS video sharing plan (2026-04-13).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The graphicsLayer transform read imageBounds on every draw, so any
layout change during the grow animation (an async image finishing
loading, a HorizontalPager settling, etc.) would re-target the
transform and cause a visible stutter near the middle of the
transition.
Wait for the first measured bounds before starting the enter
animation, and block further imageBounds updates while the progress
Animatable is running. Updates are accepted again once the animation
settles so the exit animation still uses the latest (post-navigation
or post-load) bounds as the shrink target.
Previously the graphicsLayer transform wrapped the whole dialog Surface,
so at progress=0 the dialog's top edge (with the back button) aligned
with the source thumbnail's top edge and the actual image appeared
below it.
Move the transform down to a Box that wraps just the image/video
container inside DialogContent, and use the image's natural layout
bounds (captured via a new onContentBoundsChanged callback on
RenderImageOrVideo's Row) as the animation target. This makes the
image's own borders align with the tapped thumbnail's borders on
enter and exit.
The controls row (back/share/download) now lives outside the transform
with alpha tied to the progress value so it fades in alongside the
grow animation and fades out with the shrink animation.