feat(hls): auto-publish kind:1 sibling note with rich imeta
This commit is contained in:
+91
@@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.uploads.hls
|
||||||
|
|
||||||
|
import com.davotoula.lightcompressor.hls.HlsContentTypes
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.references.reference
|
||||||
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.nip71Video.blurhash
|
||||||
|
import com.vitorpamplona.quartz.nip71Video.dims
|
||||||
|
import com.vitorpamplona.quartz.nip71Video.hash
|
||||||
|
import com.vitorpamplona.quartz.nip71Video.image
|
||||||
|
import com.vitorpamplona.quartz.nip71Video.mimeType
|
||||||
|
import com.vitorpamplona.quartz.nip71Video.thumbhash
|
||||||
|
import com.vitorpamplona.quartz.nip92IMeta.imeta
|
||||||
|
import com.vitorpamplona.quartz.nip92IMeta.imetaTagBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the kind:1 short-note that's published as a sibling of the NIP-71 video event from
|
||||||
|
* Amethyst's HLS publish flow. Receivers that don't speak NIP-71 can still render a rich
|
||||||
|
* preview (poster + dim + blurhash/thumbhash) from the kind:1 alone, while NIP-71-aware
|
||||||
|
* receivers can hop to the addressable form via the `a` tag.
|
||||||
|
*
|
||||||
|
* Tag layout:
|
||||||
|
* - One `imeta` mirroring the master rendition (url=master m3u8, m=hls, x=master sha256,
|
||||||
|
* dim=master dim, image=poster, blurhash, thumbhash).
|
||||||
|
* - One `r` tag with the master m3u8 URL (idiomatic kind:1 reference).
|
||||||
|
*
|
||||||
|
* Content mirrors the previous "Draft note" layout: `title\n\ndescription\n\nmasterUrl`,
|
||||||
|
* blank fields skipped.
|
||||||
|
*
|
||||||
|
* Intentionally omits an `a` tag back-reference to the NIP-71 event: in practice Amethyst's
|
||||||
|
* note renderer prefers the embedded addressable form when an `a` tag is present, and falls
|
||||||
|
* back to a placeholder if the relay set hasn't returned it yet — masking the rich imeta we
|
||||||
|
* just added. Without `a`, the kind:1 renders as a vanilla rich-imeta video note across
|
||||||
|
* clients, which is what users expect from a shared video link.
|
||||||
|
*/
|
||||||
|
object HlsKind1SiblingBuilder {
|
||||||
|
fun build(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
masterUrl: String,
|
||||||
|
masterSha256: String?,
|
||||||
|
masterDimension: DimensionTag?,
|
||||||
|
posterUrl: String?,
|
||||||
|
blurhashValue: String?,
|
||||||
|
thumbhashValue: String?,
|
||||||
|
createdAt: Long? = null,
|
||||||
|
): EventTemplate<TextNoteEvent> {
|
||||||
|
val content =
|
||||||
|
listOf(title, description, masterUrl)
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.joinToString("\n\n")
|
||||||
|
|
||||||
|
val masterImeta =
|
||||||
|
imetaTagBuilder(masterUrl) {
|
||||||
|
mimeType(HlsContentTypes.HLS_PLAYLIST)
|
||||||
|
masterSha256?.let { hash(it) }
|
||||||
|
masterDimension?.let { dims(it) }
|
||||||
|
posterUrl?.let { image(it) }
|
||||||
|
blurhashValue?.let { blurhash(it) }
|
||||||
|
thumbhashValue?.let { thumbhash(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextNoteEvent.build(content, createdAt ?: TimeUtils.now()) {
|
||||||
|
imeta(masterImeta)
|
||||||
|
reference(masterUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-20
@@ -71,6 +71,20 @@ sealed class HlsVideoEventTemplate {
|
|||||||
) : HlsVideoEventTemplate()
|
) : HlsVideoEventTemplate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of [HlsVideoEventBuilder.build]. Exposes the unsigned NIP-71 [template] plus the
|
||||||
|
* resolved coordinates the caller needs to construct a matching kind:1 sibling note: the
|
||||||
|
* orientation [kind] (34235/34236), the addressable [dTag] (replayable into an `a` tag), and
|
||||||
|
* the master [masterDimension] (largest rendition's WxH) so the kind:1 imeta carries the same
|
||||||
|
* dim as the NIP-71 master imeta.
|
||||||
|
*/
|
||||||
|
data class HlsBuiltTemplate(
|
||||||
|
val template: HlsVideoEventTemplate,
|
||||||
|
val kind: Int,
|
||||||
|
val dTag: String,
|
||||||
|
val masterDimension: DimensionTag?,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assembles a NIP-71 VideoHorizontalEvent / VideoVerticalEvent template from an HLS upload
|
* Assembles a NIP-71 VideoHorizontalEvent / VideoVerticalEvent template from an HLS upload
|
||||||
* result. Orientation is decided from the first rendition's width/height: portrait
|
* result. Orientation is decided from the first rendition's width/height: portrait
|
||||||
@@ -86,7 +100,7 @@ sealed class HlsVideoEventTemplate {
|
|||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalUuidApi::class)
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
object HlsVideoEventBuilder {
|
object HlsVideoEventBuilder {
|
||||||
fun build(input: HlsVideoPublishInput): HlsVideoEventTemplate {
|
fun build(input: HlsVideoPublishInput): HlsBuiltTemplate {
|
||||||
val firstRendition = input.renditions.firstOrNull()
|
val firstRendition = input.renditions.firstOrNull()
|
||||||
val isVertical = firstRendition != null && firstRendition.height > firstRendition.width
|
val isVertical = firstRendition != null && firstRendition.height > firstRendition.width
|
||||||
|
|
||||||
@@ -131,24 +145,28 @@ object HlsVideoEventBuilder {
|
|||||||
val dTag = input.dTag ?: Uuid.random().toString()
|
val dTag = input.dTag ?: Uuid.random().toString()
|
||||||
val createdAt = input.createdAt ?: TimeUtils.now()
|
val createdAt = input.createdAt ?: TimeUtils.now()
|
||||||
|
|
||||||
return if (isVertical) {
|
val template =
|
||||||
HlsVideoEventTemplate.Vertical(
|
if (isVertical) {
|
||||||
VideoVerticalEvent.build(input.description, dTag, createdAt) {
|
HlsVideoEventTemplate.Vertical(
|
||||||
videoIMetas(videoMetas)
|
VideoVerticalEvent.build(input.description, dTag, createdAt) {
|
||||||
title(input.title)
|
videoIMetas(videoMetas)
|
||||||
input.durationSeconds?.let { duration(it) }
|
title(input.title)
|
||||||
input.contentWarning?.let { contentWarning(it) }
|
input.durationSeconds?.let { duration(it) }
|
||||||
},
|
input.contentWarning?.let { contentWarning(it) }
|
||||||
)
|
},
|
||||||
} else {
|
)
|
||||||
HlsVideoEventTemplate.Horizontal(
|
} else {
|
||||||
VideoHorizontalEvent.build(input.description, dTag, createdAt) {
|
HlsVideoEventTemplate.Horizontal(
|
||||||
videoIMetas(videoMetas)
|
VideoHorizontalEvent.build(input.description, dTag, createdAt) {
|
||||||
title(input.title)
|
videoIMetas(videoMetas)
|
||||||
input.durationSeconds?.let { duration(it) }
|
title(input.title)
|
||||||
input.contentWarning?.let { contentWarning(it) }
|
input.durationSeconds?.let { duration(it) }
|
||||||
},
|
input.contentWarning?.let { contentWarning(it) }
|
||||||
)
|
},
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val kind = if (isVertical) VideoVerticalEvent.KIND else VideoHorizontalEvent.KIND
|
||||||
|
return HlsBuiltTemplate(template, kind, dTag, masterDimension)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-3
@@ -31,10 +31,13 @@ import com.davotoula.lightcompressor.hls.Rendition
|
|||||||
import com.davotoula.lightcompressor.hls.SimpleHlsListener
|
import com.davotoula.lightcompressor.hls.SimpleHlsListener
|
||||||
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
|
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
|
||||||
import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploader
|
import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploader
|
||||||
|
import com.vitorpamplona.amethyst.service.uploads.hls.HlsKind1SiblingBuilder
|
||||||
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventBuilder
|
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventBuilder
|
||||||
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate
|
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate
|
||||||
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoPublishInput
|
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoPublishInput
|
||||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -88,7 +91,15 @@ class HlsPublishOrchestrator(
|
|||||||
) -> HlsUploadResult<MediaUploadResult>,
|
) -> HlsUploadResult<MediaUploadResult>,
|
||||||
private val buildUploader: (ServerName) -> HlsBlobUploader,
|
private val buildUploader: (ServerName) -> HlsBlobUploader,
|
||||||
private val uploadMaster: suspend (HlsBlobUploader, String) -> MediaUploadResult,
|
private val uploadMaster: suspend (HlsBlobUploader, String) -> MediaUploadResult,
|
||||||
private val signAndPublish: suspend (HlsVideoEventTemplate) -> String,
|
// Signs and broadcasts the NIP-71 video event AND the kind:1 sibling note. The
|
||||||
|
// orchestrator hands the primary template plus a lazily-evaluated [buildSibling] closure
|
||||||
|
// that produces the kind:1 template — lazy so the orchestrator can capture the master
|
||||||
|
// metadata in scope without forcing kind:1 construction before the NIP-71 sign. Returns
|
||||||
|
// the NIP-71 event id.
|
||||||
|
private val signAndPublish: suspend (
|
||||||
|
primary: HlsVideoEventTemplate,
|
||||||
|
buildSibling: () -> EventTemplate<TextNoteEvent>,
|
||||||
|
) -> String,
|
||||||
// Generates a poster JPEG from the picked source video and uploads it via the supplied
|
// Generates a poster JPEG from the picked source video and uploads it via the supplied
|
||||||
// uploader, returning the public URL. Returns null if poster generation isn't possible
|
// uploader, returning the public URL. Returns null if poster generation isn't possible
|
||||||
// (unsupported source, decode failure, no readable frame). Failures here must NOT fail
|
// (unsupported source, decode failure, no readable frame). Failures here must NOT fail
|
||||||
@@ -231,7 +242,7 @@ class HlsPublishOrchestrator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
_state.value = HlsPublishState.Publishing
|
_state.value = HlsPublishState.Publishing
|
||||||
val template =
|
val built =
|
||||||
HlsVideoEventBuilder.build(
|
HlsVideoEventBuilder.build(
|
||||||
HlsVideoPublishInput(
|
HlsVideoPublishInput(
|
||||||
renditions = uploadResult.renditions,
|
renditions = uploadResult.renditions,
|
||||||
@@ -247,7 +258,19 @@ class HlsPublishOrchestrator(
|
|||||||
thumbhash = posterResult?.thumbhash,
|
thumbhash = posterResult?.thumbhash,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val eventId = signAndPublish(template)
|
val buildSibling: () -> EventTemplate<TextNoteEvent> = {
|
||||||
|
HlsKind1SiblingBuilder.build(
|
||||||
|
title = request.title,
|
||||||
|
description = request.description,
|
||||||
|
masterUrl = masterUrl,
|
||||||
|
masterSha256 = masterUpload.sha256,
|
||||||
|
masterDimension = built.masterDimension,
|
||||||
|
posterUrl = posterResult?.url,
|
||||||
|
blurhashValue = posterResult?.blurhash,
|
||||||
|
thumbhashValue = posterResult?.thumbhash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val eventId = signAndPublish(built.template, buildSibling)
|
||||||
|
|
||||||
_state.value =
|
_state.value =
|
||||||
HlsPublishState.Success(
|
HlsPublishState.Success(
|
||||||
|
|||||||
+16
-1
@@ -88,7 +88,7 @@ fun createProductionHlsPublishOrchestrator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signAndPublish = { template ->
|
signAndPublish = { template, buildSibling ->
|
||||||
val inner =
|
val inner =
|
||||||
when (template) {
|
when (template) {
|
||||||
is HlsVideoEventTemplate.Horizontal -> template.template
|
is HlsVideoEventTemplate.Horizontal -> template.template
|
||||||
@@ -96,6 +96,21 @@ fun createProductionHlsPublishOrchestrator(
|
|||||||
}
|
}
|
||||||
val signed = account.signer.sign(inner)
|
val signed = account.signer.sign(inner)
|
||||||
account.sendAutomatic(signed)
|
account.sendAutomatic(signed)
|
||||||
|
|
||||||
|
// Auto-publish the kind:1 sibling note so receivers that don't speak NIP-71 still
|
||||||
|
// see a rich preview (poster + dim + blurhash/thumbhash) and can hop to the
|
||||||
|
// addressable form via the imeta + `a` tag. Publishing the sibling must not throw
|
||||||
|
// out of the orchestrator on signer failure; the NIP-71 event is already broadcast
|
||||||
|
// and surfacing as a partial success is more useful than a hard fail.
|
||||||
|
val siblingTemplate = buildSibling()
|
||||||
|
try {
|
||||||
|
val signedSibling = account.signer.sign(siblingTemplate)
|
||||||
|
account.sendAutomatic(signedSibling)
|
||||||
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w(TAG) { "kind:1 sibling sign/publish failed: ${e.message}" }
|
||||||
|
}
|
||||||
signed.id
|
signed.id
|
||||||
},
|
},
|
||||||
uploadPoster = { uploader ->
|
uploadPoster = { uploader ->
|
||||||
|
|||||||
+8
-70
@@ -83,7 +83,6 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
|||||||
import com.vitorpamplona.amethyst.ui.components.TextSpinner
|
import com.vitorpamplona.amethyst.ui.components.TextSpinner
|
||||||
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
|
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
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.navigation.topbars.TopBarWithBackButton
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
@@ -323,31 +322,6 @@ private fun FormFields(vm: NewHlsVideoViewModel) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
|
||||||
// Draft-a-note-after-upload toggle — opens the existing short-note composer prefilled
|
|
||||||
// with the title, description and master playlist URL; the user edits and posts.
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.hls_draft_note_after_upload),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.hls_draft_note_after_upload_explainer),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = vm.draftNoteAfterUpload,
|
|
||||||
onCheckedChange = { vm.draftNoteAfterUpload = it },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// Server picker — reads the user's configured Blossom servers from the account
|
// Server picker — reads the user's configured Blossom servers from the account
|
||||||
@@ -686,54 +660,18 @@ private fun SuccessBody(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
if (vm.draftNoteAfterUpload) {
|
Button(
|
||||||
Button(
|
onClick = {
|
||||||
onClick = {
|
vm.reset()
|
||||||
val draft = buildDraftNoteText(vm.title, vm.description, state.masterUrl)
|
nav.popBack()
|
||||||
vm.reset()
|
},
|
||||||
// Pop the HLS publish screen off the back stack as we open the composer,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
// so that after the user posts (or backs out of) the draft they land on
|
) {
|
||||||
// the screen they came from, not back on the HLS publish flow.
|
Text(stringResource(R.string.hls_done))
|
||||||
nav.popUpTo(Route.NewShortNote(message = draft), Route.NewHlsVideo::class)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.hls_draft_note_button))
|
|
||||||
}
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildDraftNoteText(
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
masterUrl: String,
|
|
||||||
): String =
|
|
||||||
listOf(title, description, masterUrl)
|
|
||||||
.map { it.trim() }
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.joinToString("\n\n")
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FailureBody(
|
private fun FailureBody(
|
||||||
vm: NewHlsVideoViewModel,
|
vm: NewHlsVideoViewModel,
|
||||||
|
|||||||
-1
@@ -64,7 +64,6 @@ open class NewHlsVideoViewModel : ViewModel() {
|
|||||||
var sensitiveContent by mutableStateOf(false)
|
var sensitiveContent by mutableStateOf(false)
|
||||||
var contentWarningReason by mutableStateOf("")
|
var contentWarningReason by mutableStateOf("")
|
||||||
var useH265 by mutableStateOf(true)
|
var useH265 by mutableStateOf(true)
|
||||||
var draftNoteAfterUpload by mutableStateOf(true)
|
|
||||||
var selectedServer by mutableStateOf<ServerName?>(null)
|
var selectedServer by mutableStateOf<ServerName?>(null)
|
||||||
|
|
||||||
var selectedRenditionLabels by mutableStateOf(
|
var selectedRenditionLabels by mutableStateOf(
|
||||||
|
|||||||
@@ -2586,9 +2586,6 @@
|
|||||||
<string name="hls_view_note">View note</string>
|
<string name="hls_view_note">View note</string>
|
||||||
<string name="hls_done">Done</string>
|
<string name="hls_done">Done</string>
|
||||||
<string name="hls_try_again">Try again</string>
|
<string name="hls_try_again">Try again</string>
|
||||||
<string name="hls_draft_note_after_upload">Draft note after upload</string>
|
|
||||||
<string name="hls_draft_note_after_upload_explainer">Open the note composer pre-filled with the title, description and video link so you can tweak it before posting.</string>
|
|
||||||
<string name="hls_draft_note_button">Draft note</string>
|
|
||||||
|
|
||||||
<string name="pack_actions_dialog_title">Pack Actions</string>
|
<string name="pack_actions_dialog_title">Pack Actions</string>
|
||||||
<string name="list_actions_dialog_title">List Actions</string>
|
<string name="list_actions_dialog_title">List Actions</string>
|
||||||
|
|||||||
+11
-11
@@ -161,7 +161,7 @@ class HlsPublishOrchestratorTest {
|
|||||||
runUpload = fakeRunUpload(),
|
runUpload = fakeRunUpload(),
|
||||||
buildUploader = { canned },
|
buildUploader = { canned },
|
||||||
uploadMaster = fakeUploadMaster(canned),
|
uploadMaster = fakeUploadMaster(canned),
|
||||||
signAndPublish = { tpl ->
|
signAndPublish = { tpl, _ ->
|
||||||
publishedTemplates += tpl
|
publishedTemplates += tpl
|
||||||
"signed-event-id"
|
"signed-event-id"
|
||||||
},
|
},
|
||||||
@@ -221,7 +221,7 @@ class HlsPublishOrchestratorTest {
|
|||||||
runUpload = capturingRunUpload,
|
runUpload = capturingRunUpload,
|
||||||
buildUploader = { canned },
|
buildUploader = { canned },
|
||||||
uploadMaster = fakeUploadMaster(canned),
|
uploadMaster = fakeUploadMaster(canned),
|
||||||
signAndPublish = {
|
signAndPublish = { _, _ ->
|
||||||
capturedDuringPublish += orchestrator.state.value
|
capturedDuringPublish += orchestrator.state.value
|
||||||
"event-id"
|
"event-id"
|
||||||
},
|
},
|
||||||
@@ -247,7 +247,7 @@ class HlsPublishOrchestratorTest {
|
|||||||
runUpload = { _, _, _ -> throw RuntimeException("decode failed") },
|
runUpload = { _, _, _ -> throw RuntimeException("decode failed") },
|
||||||
buildUploader = { CannedUploader() },
|
buildUploader = { CannedUploader() },
|
||||||
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
|
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
|
||||||
signAndPublish = { "never" },
|
signAndPublish = { _, _ -> "never" },
|
||||||
)
|
)
|
||||||
|
|
||||||
runBlocking { orchestrator.publish(newRequest()) }
|
runBlocking { orchestrator.publish(newRequest()) }
|
||||||
@@ -267,7 +267,7 @@ class HlsPublishOrchestratorTest {
|
|||||||
HlsBlobUploader { _, _, _ -> throw RuntimeException("server 500") }
|
HlsBlobUploader { _, _, _ -> throw RuntimeException("server 500") }
|
||||||
},
|
},
|
||||||
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
|
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
|
||||||
signAndPublish = { "never" },
|
signAndPublish = { _, _ -> "never" },
|
||||||
)
|
)
|
||||||
|
|
||||||
runBlocking { orchestrator.publish(newRequest()) }
|
runBlocking { orchestrator.publish(newRequest()) }
|
||||||
@@ -286,7 +286,7 @@ class HlsPublishOrchestratorTest {
|
|||||||
runUpload = fakeRunUpload(),
|
runUpload = fakeRunUpload(),
|
||||||
buildUploader = { canned },
|
buildUploader = { canned },
|
||||||
uploadMaster = { _, _ -> throw RuntimeException("master upload failed") },
|
uploadMaster = { _, _ -> throw RuntimeException("master upload failed") },
|
||||||
signAndPublish = { "never" },
|
signAndPublish = { _, _ -> "never" },
|
||||||
)
|
)
|
||||||
|
|
||||||
runBlocking { orchestrator.publish(newRequest()) }
|
runBlocking { orchestrator.publish(newRequest()) }
|
||||||
@@ -305,7 +305,7 @@ class HlsPublishOrchestratorTest {
|
|||||||
runUpload = fakeRunUpload(),
|
runUpload = fakeRunUpload(),
|
||||||
buildUploader = { canned },
|
buildUploader = { canned },
|
||||||
uploadMaster = fakeUploadMaster(canned),
|
uploadMaster = fakeUploadMaster(canned),
|
||||||
signAndPublish = { throw RuntimeException("relay rejected") },
|
signAndPublish = { _, _ -> throw RuntimeException("relay rejected") },
|
||||||
)
|
)
|
||||||
|
|
||||||
runBlocking { orchestrator.publish(newRequest()) }
|
runBlocking { orchestrator.publish(newRequest()) }
|
||||||
@@ -325,7 +325,7 @@ class HlsPublishOrchestratorTest {
|
|||||||
runUpload = fakeRunUpload(),
|
runUpload = fakeRunUpload(),
|
||||||
buildUploader = { canned },
|
buildUploader = { canned },
|
||||||
uploadMaster = fakeUploadMaster(canned),
|
uploadMaster = fakeUploadMaster(canned),
|
||||||
signAndPublish = { tpl ->
|
signAndPublish = { tpl, _ ->
|
||||||
captured += tpl
|
captured += tpl
|
||||||
"event-id"
|
"event-id"
|
||||||
},
|
},
|
||||||
@@ -363,7 +363,7 @@ class HlsPublishOrchestratorTest {
|
|||||||
runUpload = fakeRunUpload(portrait),
|
runUpload = fakeRunUpload(portrait),
|
||||||
buildUploader = { canned },
|
buildUploader = { canned },
|
||||||
uploadMaster = fakeUploadMaster(canned),
|
uploadMaster = fakeUploadMaster(canned),
|
||||||
signAndPublish = { tpl ->
|
signAndPublish = { tpl, _ ->
|
||||||
captured += tpl
|
captured += tpl
|
||||||
"event-id"
|
"event-id"
|
||||||
},
|
},
|
||||||
@@ -384,7 +384,7 @@ class HlsPublishOrchestratorTest {
|
|||||||
runUpload = fakeRunUpload(),
|
runUpload = fakeRunUpload(),
|
||||||
buildUploader = { canned },
|
buildUploader = { canned },
|
||||||
uploadMaster = fakeUploadMaster(canned),
|
uploadMaster = fakeUploadMaster(canned),
|
||||||
signAndPublish = { tpl ->
|
signAndPublish = { tpl, _ ->
|
||||||
captured += tpl
|
captured += tpl
|
||||||
"event-id"
|
"event-id"
|
||||||
},
|
},
|
||||||
@@ -412,7 +412,7 @@ class HlsPublishOrchestratorTest {
|
|||||||
runUpload = fakeRunUpload(),
|
runUpload = fakeRunUpload(),
|
||||||
buildUploader = { canned },
|
buildUploader = { canned },
|
||||||
uploadMaster = fakeUploadMaster(canned),
|
uploadMaster = fakeUploadMaster(canned),
|
||||||
signAndPublish = { tpl ->
|
signAndPublish = { tpl, _ ->
|
||||||
captured += tpl
|
captured += tpl
|
||||||
"event-id"
|
"event-id"
|
||||||
},
|
},
|
||||||
@@ -440,7 +440,7 @@ class HlsPublishOrchestratorTest {
|
|||||||
runUpload = { _, _, _ -> throw RuntimeException("boom") },
|
runUpload = { _, _, _ -> throw RuntimeException("boom") },
|
||||||
buildUploader = { CannedUploader() },
|
buildUploader = { CannedUploader() },
|
||||||
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
|
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
|
||||||
signAndPublish = { "never" },
|
signAndPublish = { _, _ -> "never" },
|
||||||
)
|
)
|
||||||
|
|
||||||
runBlocking { orchestrator.publish(newRequest()) }
|
runBlocking { orchestrator.publish(newRequest()) }
|
||||||
|
|||||||
+14
-14
@@ -120,8 +120,8 @@ class HlsVideoEventBuilderTest {
|
|||||||
fun landscapeRenditionsBuildHorizontalTemplateKind34235() {
|
fun landscapeRenditionsBuildHorizontalTemplateKind34235() {
|
||||||
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
||||||
|
|
||||||
assertTrue("expected Horizontal template", result is HlsVideoEventTemplate.Horizontal)
|
assertTrue("expected Horizontal template", result.template is HlsVideoEventTemplate.Horizontal)
|
||||||
val template = (result as HlsVideoEventTemplate.Horizontal).template
|
val template = (result.template as HlsVideoEventTemplate.Horizontal).template
|
||||||
assertEquals(VideoHorizontalEvent.KIND, template.kind)
|
assertEquals(VideoHorizontalEvent.KIND, template.kind)
|
||||||
assertEquals("A cool video", template.content)
|
assertEquals("A cool video", template.content)
|
||||||
}
|
}
|
||||||
@@ -130,15 +130,15 @@ class HlsVideoEventBuilderTest {
|
|||||||
fun portraitRenditionsBuildVerticalTemplateKind34236() {
|
fun portraitRenditionsBuildVerticalTemplateKind34236() {
|
||||||
val result = HlsVideoEventBuilder.build(input(portraitRenditions))
|
val result = HlsVideoEventBuilder.build(input(portraitRenditions))
|
||||||
|
|
||||||
assertTrue("expected Vertical template", result is HlsVideoEventTemplate.Vertical)
|
assertTrue("expected Vertical template", result.template is HlsVideoEventTemplate.Vertical)
|
||||||
val template = (result as HlsVideoEventTemplate.Vertical).template
|
val template = (result.template as HlsVideoEventTemplate.Vertical).template
|
||||||
assertEquals(VideoVerticalEvent.KIND, template.kind)
|
assertEquals(VideoVerticalEvent.KIND, template.kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun horizontalTemplateHasTitleAndDTag() {
|
fun horizontalTemplateHasTitleAndDTag() {
|
||||||
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
||||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||||
|
|
||||||
val title = tags.findTag("title")
|
val title = tags.findTag("title")
|
||||||
assertNotNull(title)
|
assertNotNull(title)
|
||||||
@@ -152,7 +152,7 @@ class HlsVideoEventBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
fun templateContainsOneImetaForMasterAndOnePerRendition() {
|
fun templateContainsOneImetaForMasterAndOnePerRendition() {
|
||||||
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
||||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||||
|
|
||||||
val imetas = tags.findAllTags("imeta")
|
val imetas = tags.findAllTags("imeta")
|
||||||
// 1 master + 2 renditions
|
// 1 master + 2 renditions
|
||||||
@@ -181,7 +181,7 @@ class HlsVideoEventBuilderTest {
|
|||||||
HlsVideoEventBuilder.build(
|
HlsVideoEventBuilder.build(
|
||||||
input(landscapeRenditions, duration = 123),
|
input(landscapeRenditions, duration = 123),
|
||||||
)
|
)
|
||||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||||
|
|
||||||
val duration = tags.findTag("duration")
|
val duration = tags.findTag("duration")
|
||||||
assertNotNull(duration)
|
assertNotNull(duration)
|
||||||
@@ -191,7 +191,7 @@ class HlsVideoEventBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
fun noDurationTagWhenNotProvided() {
|
fun noDurationTagWhenNotProvided() {
|
||||||
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
||||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||||
assertNull(tags.findTag("duration"))
|
assertNull(tags.findTag("duration"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ class HlsVideoEventBuilderTest {
|
|||||||
HlsVideoEventBuilder.build(
|
HlsVideoEventBuilder.build(
|
||||||
input(landscapeRenditions, contentWarning = "NSFW"),
|
input(landscapeRenditions, contentWarning = "NSFW"),
|
||||||
)
|
)
|
||||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||||
|
|
||||||
val warning = tags.findTag("content-warning")
|
val warning = tags.findTag("content-warning")
|
||||||
assertNotNull(warning)
|
assertNotNull(warning)
|
||||||
@@ -211,14 +211,14 @@ class HlsVideoEventBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
fun noContentWarningTagWhenNull() {
|
fun noContentWarningTagWhenNull() {
|
||||||
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
||||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||||
assertNull(tags.findTag("content-warning"))
|
assertNull(tags.findTag("content-warning"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun horizontalTemplateCarriesAutoGeneratedAltTag() {
|
fun horizontalTemplateCarriesAutoGeneratedAltTag() {
|
||||||
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
||||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||||
val alt = tags.findTag("alt")
|
val alt = tags.findTag("alt")
|
||||||
assertNotNull(alt)
|
assertNotNull(alt)
|
||||||
assertEquals(VideoHorizontalEvent.ALT_DESCRIPTION, alt!![1])
|
assertEquals(VideoHorizontalEvent.ALT_DESCRIPTION, alt!![1])
|
||||||
@@ -227,7 +227,7 @@ class HlsVideoEventBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
fun verticalTemplateCarriesVerticalAltTag() {
|
fun verticalTemplateCarriesVerticalAltTag() {
|
||||||
val result = HlsVideoEventBuilder.build(input(portraitRenditions))
|
val result = HlsVideoEventBuilder.build(input(portraitRenditions))
|
||||||
val tags = (result as HlsVideoEventTemplate.Vertical).template.tags
|
val tags = (result.template as HlsVideoEventTemplate.Vertical).template.tags
|
||||||
val alt = tags.findTag("alt")
|
val alt = tags.findTag("alt")
|
||||||
assertNotNull(alt)
|
assertNotNull(alt)
|
||||||
assertEquals(VideoVerticalEvent.ALT_DESCRIPTION, alt!![1])
|
assertEquals(VideoVerticalEvent.ALT_DESCRIPTION, alt!![1])
|
||||||
@@ -239,7 +239,7 @@ class HlsVideoEventBuilderTest {
|
|||||||
HlsVideoEventBuilder.build(
|
HlsVideoEventBuilder.build(
|
||||||
input(landscapeRenditions, posterUrl = "https://cdn.test/poster.jpg"),
|
input(landscapeRenditions, posterUrl = "https://cdn.test/poster.jpg"),
|
||||||
)
|
)
|
||||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||||
|
|
||||||
val imetas = tags.findAllTags("imeta")
|
val imetas = tags.findAllTags("imeta")
|
||||||
// 1 master + 2 renditions, each must carry image <posterUrl>
|
// 1 master + 2 renditions, each must carry image <posterUrl>
|
||||||
@@ -253,7 +253,7 @@ class HlsVideoEventBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
fun noPosterUrlMeansNoImagePropertyOnImetas() {
|
fun noPosterUrlMeansNoImagePropertyOnImetas() {
|
||||||
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
|
||||||
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
|
val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
|
||||||
|
|
||||||
val imetas = tags.findAllTags("imeta")
|
val imetas = tags.findAllTags("imeta")
|
||||||
imetas.forEach { imeta ->
|
imetas.forEach { imeta ->
|
||||||
|
|||||||
Reference in New Issue
Block a user