feat(audio-rooms): "Listen to recording" CTA for closed rooms

Wire MeetingSpaceEvent's existing RecordingTag into the
note-view renderer. Closed rooms (status=CLOSED) that ship a
`["recording", url]` tag get an OutlinedButton that hands the
URL to the system media player via ACTION_VIEW — most users have
a podcast / audio app registered for HTTPS audio URLs.

Live and scheduled rooms keep the Join button. Closed rooms
without a recording render no CTA — there's nothing to enter.

Adds:
  * MeetingSpaceEvent.recording() accessor
  * TagArrayBuilder<MeetingSpaceEvent>.recording(url) DSL builder
  * ListenToRecordingButton composable in the note renderer

Note: nostrnests' moq-lite refactor dropped the LiveKit-era
recording HTTP surface, but the kind-30312 `recording` tag is
still spec-allowed and individual hosts can populate it via
out-of-band capture. This commit makes Amethyst the receiving
end for any host who does.
This commit is contained in:
Claude
2026-04-27 01:29:16 +00:00
parent 2ec19fe961
commit 3fb9f02b6d
4 changed files with 54 additions and 11 deletions
@@ -101,6 +101,7 @@ fun RenderMeetingSpaceEventInner(
val summary = remember(eventUpdates) { noteEvent.summary() } val summary = remember(eventUpdates) { noteEvent.summary() }
val status = remember(eventUpdates) { noteEvent.status() } val status = remember(eventUpdates) { noteEvent.status() }
val starts = remember(eventUpdates) { noteEvent.starts() } val starts = remember(eventUpdates) { noteEvent.starts() }
val recording = remember(eventUpdates) { noteEvent.recording() }
val participants = remember(eventUpdates) { noteEvent.participants() } val participants = remember(eventUpdates) { noteEvent.participants() }
Row( Row(
@@ -160,17 +161,20 @@ fun RenderMeetingSpaceEventInner(
RenderParticipants(participants, accountViewModel, nav) RenderParticipants(participants, accountViewModel, nav)
// In-feed Join button closes the deep-link loop. A // In-feed CTA. Live / scheduled rooms get a Join button; closed
// `nostr:naddr1...` for a kind-30312 lands here through // rooms with a recording get a "Listen to recording" button so
// MainActivity's URI handler → Route.Note(addressTag) → this // audience members who missed the live session can listen back.
// renderer; without a button the user has no path into the // Closed rooms without a recording stay button-less — there's
// audio room. JoinAudioRoomButton hides itself when the // nothing to enter.
// event lacks service / endpoint / d-tag. Row(
if (status != MeetingSpaceStatusTag.STATUS.CLOSED) { modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
Row( horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp), ) {
horizontalArrangement = Arrangement.End, if (status == MeetingSpaceStatusTag.STATUS.CLOSED) {
) { recording?.let {
ListenToRecordingButton(url = it)
}
} else {
com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room.JoinAudioRoomButton( com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room.JoinAudioRoomButton(
event = noteEvent, event = noteEvent,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
@@ -179,6 +183,29 @@ fun RenderMeetingSpaceEventInner(
} }
} }
/**
* Hand off a kind-30312 `recording` URL to the system media
* player via ACTION_VIEW. Most users have a podcast / audio app
* registered for `https://...mp3`-style URLs; the system picker
* lets them choose. Stays out of the in-app audio path on
* purpose — Amethyst doesn't ship its own audio player surface.
*/
@Composable
private fun ListenToRecordingButton(url: String) {
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.material3.OutlinedButton(onClick = {
runCatching {
context.startActivity(
android.content
.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
}) {
Text(stringRes(R.string.audio_room_listen_to_recording))
}
}
@Composable @Composable
fun RenderMeetingRoomEvent( fun RenderMeetingRoomEvent(
baseNote: Note, baseNote: Note,
+1
View File
@@ -503,6 +503,7 @@
<string name="audio_room_connecting_handshake">Negotiating audio</string> <string name="audio_room_connecting_handshake">Negotiating audio</string>
<string name="audio_room_connected">Audio connected</string> <string name="audio_room_connected">Audio connected</string>
<string name="audio_room_reconnecting">Reconnecting…</string> <string name="audio_room_reconnecting">Reconnecting…</string>
<string name="audio_room_listen_to_recording">Listen to recording</string>
<string name="audio_room_audio_failed">Audio failed: %1$s</string> <string name="audio_room_audio_failed">Audio failed: %1$s</string>
<string name="audio_room_audio_unavailable">Audio is not available for this room</string> <string name="audio_room_audio_unavailable">Audio is not available for this room</string>
<string name="audio_room_talk">Talk</string> <string name="audio_room_talk">Talk</string>
@@ -93,6 +93,15 @@ class MeetingSpaceEvent(
*/ */
fun font() = tags.firstNotNullOfOrNull(com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.FontTag::parse) fun font() = tags.firstNotNullOfOrNull(com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.FontTag::parse)
/**
* Optional URL to a post-room recording. Hosts that record a
* room re-publish the kind-30312 with `status=closed` and
* `["recording", url]` so audience members who missed the live
* session can listen back. Returns null when no recording is
* available.
*/
fun recording() = tags.firstNotNullOfOrNull(com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RecordingTag::parse)
/** /**
* Scheduled start time as unix seconds, only meaningful when * Scheduled start time as unix seconds, only meaningful when
* [status] is [StatusTag.STATUS.PLANNED]. Returns null on * [status] is [StatusTag.STATUS.PLANNED]. Returns null on
@@ -61,6 +61,12 @@ fun TagArrayBuilder<MeetingSpaceEvent>.font(
.assemble(family, url), .assemble(family, url),
) )
fun TagArrayBuilder<MeetingSpaceEvent>.recording(url: String) =
addUnique(
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RecordingTag
.assemble(url),
)
fun TagArrayBuilder<MeetingSpaceEvent>.relays(urls: List<NormalizedRelayUrl>) = addUnique(RelayListTag.assemble(urls)) fun TagArrayBuilder<MeetingSpaceEvent>.relays(urls: List<NormalizedRelayUrl>) = addUnique(RelayListTag.assemble(urls))
fun TagArrayBuilder<MeetingSpaceEvent>.participant( fun TagArrayBuilder<MeetingSpaceEvent>.participant(