diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt index 58682fd17..9c4798e1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt @@ -41,6 +41,7 @@ data class HlsPublishRequest( val codec: VideoCodec, val server: ServerName, val durationSeconds: Int? = null, + val crossPostAsNote: Boolean = true, ) /** @@ -60,6 +61,7 @@ class HlsPublishOrchestrator( ) -> HlsBundle, private val buildUploader: (ServerName) -> HlsBlobUploader, private val signAndPublish: suspend (HlsVideoEventTemplate) -> String, + private val signAndPublishNote: suspend (content: String) -> String, private val workDirFactory: () -> File, ) { val state: StateFlow = _state @@ -96,7 +98,20 @@ class HlsPublishOrchestrator( ) val eventId = signAndPublish(template) - _state.value = HlsPublishState.Success(eventId = eventId, masterUrl = uploadResult.masterUrl) + val noteEventId = + if (request.crossPostAsNote) { + val content = buildCompanionNoteContent(request, uploadResult.masterUrl) + signAndPublishNote(content) + } else { + null + } + + _state.value = + HlsPublishState.Success( + eventId = eventId, + masterUrl = uploadResult.masterUrl, + noteEventId = noteEventId, + ) } catch (e: CancellationException) { _state.value = HlsPublishState.Failure(message = "Cancelled") throw e @@ -110,4 +125,13 @@ class HlsPublishOrchestrator( } private fun contentWarningOrNull(request: HlsPublishRequest): String? = if (request.sensitiveContent) request.contentWarningReason else null + + private fun buildCompanionNoteContent( + request: HlsPublishRequest, + masterUrl: String, + ): String = + listOf(request.title, request.description, masterUrl) + .map { it.trim() } + .filter { it.isNotEmpty() } + .joinToString("\n\n") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt index 659a124c0..2810c7aab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploaderFactory import com.vitorpamplona.amethyst.service.uploads.hls.HlsTranscoder import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.flow.MutableStateFlow import java.io.File @@ -67,6 +68,11 @@ fun createProductionHlsPublishOrchestrator( account.sendAutomatic(signed) signed.id }, + signAndPublishNote = { content -> + val signed = account.signer.sign(TextNoteEvent.build(content)) + account.sendAutomatic(signed) + signed.id + }, workDirFactory = { File(context.cacheDir, "hls-${System.currentTimeMillis()}").apply { mkdirs() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt index da48161d1..8b782938e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt @@ -38,6 +38,7 @@ sealed class HlsPublishState { data class Success( val eventId: String, val masterUrl: String, + val noteEventId: String? = null, ) : HlsPublishState() data class Failure( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt index 075a4fa78..c9a2a578b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt @@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.collections.immutable.toImmutableList @@ -318,6 +319,30 @@ private fun FormFields(vm: NewHlsVideoViewModel) { ) } + Spacer(Modifier.height(8.dp)) + + // Cross-post as kind-1 note toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.hls_cross_post_as_note), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.hls_cross_post_as_note_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = vm.crossPostAsNote, + onCheckedChange = { vm.crossPostAsNote = it }, + ) + } + Spacer(Modifier.height(16.dp)) // Server picker — reads the user's configured Blossom servers from the account @@ -587,14 +612,38 @@ private fun SuccessBody( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(16.dp)) - Button( - onClick = { - vm.reset() - nav.popBack() - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.hls_done)) + + val noteId = state.noteEventId + if (noteId != null) { + Button( + onClick = { + vm.reset() + nav.nav(Route.Note(noteId)) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.hls_view_note)) + } + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { + vm.reset() + nav.popBack() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.hls_done)) + } + } else { + Button( + onClick = { + vm.reset() + nav.popBack() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.hls_done)) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt index 79f22db89..72db7b442 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt @@ -63,6 +63,7 @@ open class NewHlsVideoViewModel : ViewModel() { var sensitiveContent by mutableStateOf(false) var contentWarningReason by mutableStateOf("") var useH265 by mutableStateOf(true) + var crossPostAsNote by mutableStateOf(true) var selectedServer by mutableStateOf(null) private val _state = MutableStateFlow(HlsPublishState.Idle) @@ -146,6 +147,7 @@ open class NewHlsVideoViewModel : ViewModel() { codec = codec, server = server, durationSeconds = sourceMetadata?.durationSeconds, + crossPostAsNote = crossPostAsNote, ) currentJob = diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f562734da..837b8b0d1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2112,6 +2112,8 @@ View note Done Try again + Cross-post as note + Also publish a regular Nostr note linking to the video so it shows in the home feed. Pack Actions List Actions diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt index fbce8d2c3..74c93d5fc 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt @@ -98,6 +98,7 @@ class HlsPublishOrchestratorTest { description: String = "A test clip", sensitive: Boolean = false, warningReason: String = "", + crossPostAsNote: Boolean = false, ) = HlsPublishRequest( title = title, description = description, @@ -105,6 +106,7 @@ class HlsPublishOrchestratorTest { contentWarningReason = warningReason, codec = VideoCodec.H265, server = server, + crossPostAsNote = crossPostAsNote, ) @Test @@ -119,6 +121,7 @@ class HlsPublishOrchestratorTest { publishedTemplates += tpl "signed-event-id" }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -161,6 +164,7 @@ class HlsPublishOrchestratorTest { capturedDuringPublish += orchestrator.state.value "event-id" }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -184,6 +188,7 @@ class HlsPublishOrchestratorTest { runTranscode = { _, _, _ -> throw RuntimeException("decode failed") }, buildUploader = { CannedUploader() }, signAndPublish = { "never" }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -204,6 +209,7 @@ class HlsPublishOrchestratorTest { HlsBlobUploader { _, _ -> throw RuntimeException("server 500") } }, signAndPublish = { "never" }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -222,6 +228,7 @@ class HlsPublishOrchestratorTest { runTranscode = { _, _, _ -> fakeBundle() }, buildUploader = { CannedUploader() }, signAndPublish = { throw RuntimeException("relay rejected") }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -244,6 +251,7 @@ class HlsPublishOrchestratorTest { captured += tpl "event-id" }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -282,6 +290,7 @@ class HlsPublishOrchestratorTest { captured += tpl "event-id" }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -290,6 +299,68 @@ class HlsPublishOrchestratorTest { assertTrue(captured.single() is HlsVideoEventTemplate.Vertical) } + @Test + fun crossPostAsNoteInvokesSignAndPublishNoteWithTitleDescriptionAndMasterUrl() { + val capturedNoteContent = mutableListOf() + val orchestrator = + HlsPublishOrchestrator( + _state = MutableStateFlow(HlsPublishState.Idle), + runTranscode = { _, _, _ -> fakeBundle() }, + buildUploader = { CannedUploader() }, + signAndPublish = { "video-event-id" }, + signAndPublishNote = { content -> + capturedNoteContent += content + "note-event-id" + }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { + orchestrator.publish( + newRequest( + title = "Sunset", + description = "Golden hour", + crossPostAsNote = true, + ), + ) + } + + assertEquals(1, capturedNoteContent.size) + val note = capturedNoteContent[0] + assertTrue("note missing title: $note", note.contains("Sunset")) + assertTrue("note missing description: $note", note.contains("Golden hour")) + assertTrue("note missing master url: $note", note.contains("https://cdn.test/")) + + val final = orchestrator.state.value as HlsPublishState.Success + assertEquals("note-event-id", final.noteEventId) + assertEquals("video-event-id", final.eventId) + } + + @Test + fun crossPostDisabledLeavesNoteEventIdNull() { + val noteCallbackInvocations = mutableListOf() + val orchestrator = + HlsPublishOrchestrator( + _state = MutableStateFlow(HlsPublishState.Idle), + runTranscode = { _, _, _ -> fakeBundle() }, + buildUploader = { CannedUploader() }, + signAndPublish = { "video-event-id" }, + signAndPublishNote = { + noteCallbackInvocations += it + "should-not-be-used" + }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { + orchestrator.publish(newRequest(crossPostAsNote = false)) + } + + assertEquals(0, noteCallbackInvocations.size) + val final = orchestrator.state.value as HlsPublishState.Success + assertEquals(null, final.noteEventId) + } + @Test fun resetRestoresIdleState() { val orchestrator = @@ -298,6 +369,7 @@ class HlsPublishOrchestratorTest { runTranscode = { _, _, _ -> throw RuntimeException("boom") }, buildUploader = { CannedUploader() }, signAndPublish = { "never" }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, )