diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsKind1SiblingBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsKind1SiblingBuilder.kt new file mode 100644 index 0000000000..3fe7fe10eb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsKind1SiblingBuilder.kt @@ -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?, + blurhash: String?, + thumbhash: String?, + createdAt: Long? = null, + ): EventTemplate { + 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) } + blurhash?.let { this.blurhash(it) } + thumbhash?.let { this.thumbhash(it) } + } + + return TextNoteEvent.build(content, createdAt ?: TimeUtils.now()) { + imeta(masterImeta) + reference(masterUrl) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt index 50fce24293..6a326f99c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt @@ -54,6 +54,9 @@ data class HlsVideoPublishInput( // (gallery thumbnails, previews) have a still to render — the .m3u8 playlist itself is a // text manifest that can't be decoded as an image frame. val posterUrl: String? = null, + // Identical for every rendition since the source frame is the same. + val blurhash: String? = null, + val thumbhash: String? = null, ) sealed class HlsVideoEventTemplate { @@ -66,6 +69,16 @@ sealed class HlsVideoEventTemplate { ) : HlsVideoEventTemplate() } +/** + * Result of [HlsVideoEventBuilder.build]. Exposes the unsigned NIP-71 [template] plus the + * master [masterDimension] (largest rendition's WxH) so the kind:1 sibling imeta can carry + * the same dim as the NIP-71 master imeta. + */ +data class HlsBuiltTemplate( + val template: HlsVideoEventTemplate, + val masterDimension: DimensionTag?, +) + /** * Assembles a NIP-71 VideoHorizontalEvent / VideoVerticalEvent template from an HLS upload * result. Orientation is decided from the first rendition's width/height: portrait @@ -81,7 +94,7 @@ sealed class HlsVideoEventTemplate { */ @OptIn(ExperimentalUuidApi::class) object HlsVideoEventBuilder { - fun build(input: HlsVideoPublishInput): HlsVideoEventTemplate { + fun build(input: HlsVideoPublishInput): HlsBuiltTemplate { val firstRendition = input.renditions.firstOrNull() val isVertical = firstRendition != null && firstRendition.height > firstRendition.width @@ -96,6 +109,8 @@ object HlsVideoEventBuilder { dimension = masterDimension, alt = input.alt, image = posterImage, + blurhash = input.blurhash, + thumbhash = input.thumbhash, ) val renditionMetas = @@ -115,6 +130,8 @@ object HlsVideoEventBuilder { size = combinedMetadata?.size?.toInt(), dimension = DimensionTag(summary.width, summary.height), image = posterImage, + blurhash = input.blurhash, + thumbhash = input.thumbhash, ) } @@ -122,24 +139,27 @@ object HlsVideoEventBuilder { val dTag = input.dTag ?: Uuid.random().toString() val createdAt = input.createdAt ?: TimeUtils.now() - return if (isVertical) { - HlsVideoEventTemplate.Vertical( - VideoVerticalEvent.build(input.description, dTag, createdAt) { - videoIMetas(videoMetas) - title(input.title) - input.durationSeconds?.let { duration(it) } - input.contentWarning?.let { contentWarning(it) } - }, - ) - } else { - HlsVideoEventTemplate.Horizontal( - VideoHorizontalEvent.build(input.description, dTag, createdAt) { - videoIMetas(videoMetas) - title(input.title) - input.durationSeconds?.let { duration(it) } - input.contentWarning?.let { contentWarning(it) } - }, - ) - } + val template = + if (isVertical) { + HlsVideoEventTemplate.Vertical( + VideoVerticalEvent.build(input.description, dTag, createdAt) { + videoIMetas(videoMetas) + title(input.title) + input.durationSeconds?.let { duration(it) } + input.contentWarning?.let { contentWarning(it) } + }, + ) + } else { + HlsVideoEventTemplate.Horizontal( + VideoHorizontalEvent.build(input.description, dTag, createdAt) { + videoIMetas(videoMetas) + title(input.title) + input.durationSeconds?.let { duration(it) } + input.contentWarning?.let { contentWarning(it) } + }, + ) + } + + return HlsBuiltTemplate(template, masterDimension) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index 7528c03abb..a298f90c1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -40,13 +40,13 @@ import androidx.media3.common.util.UnstableApi import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent -import com.davotoula.lightcompressor.hls.HlsContentTypes import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isHlsMimeType import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl import com.vitorpamplona.amethyst.commons.richtext.toCoilModel import com.vitorpamplona.amethyst.model.Note @@ -69,19 +69,6 @@ import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent -// Mirrors the canonical HLS-playlist mime list used in MediaItemCache.toExoPlayerMimeType. -// Kept inline rather than extracting a shared helper for one read-side caller. -private fun isHlsMimeType(mimeType: String?): Boolean = - when (mimeType?.lowercase()) { - HlsContentTypes.HLS_PLAYLIST, - "application/x-mpegurl", - "audio/x-mpegurl", - "audio/mpegurl", - -> true - - else -> false - } - @Composable fun GalleryThumbnail( baseNote: Note, 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 4d15e03b12..50c434fcdf 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 @@ -31,10 +31,13 @@ import com.davotoula.lightcompressor.hls.Rendition import com.davotoula.lightcompressor.hls.SimpleHlsListener import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult 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.HlsVideoEventTemplate import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoPublishInput 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 kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow @@ -55,6 +58,18 @@ data class HlsPublishRequest( val durationSeconds: Int? = null, ) +/** + * Poster JPEG upload result. Carries the public URL plus blurhash/thumbhash derived from the + * same poster pixels, so every imeta in the published NIP-71 event can render an instant + * low-res placeholder. All three fields share a single source frame; per-rendition variants + * would be redundant. + */ +data class HlsPosterUpload( + val url: String, + val blurhash: String? = null, + val thumbhash: String? = null, +) + /** * Orchestrates the transcode → upload → build → publish pipeline for a single HLS video publish. * Delegates transcoding and segment/media-playlist upload plumbing to the library's @@ -76,12 +91,17 @@ class HlsPublishOrchestrator( ) -> HlsUploadResult, private val buildUploader: (ServerName) -> HlsBlobUploader, 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. Returns the + // NIP-71 event id. + private val signAndPublish: suspend ( + primary: HlsVideoEventTemplate, + sibling: EventTemplate, + ) -> String, // 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 // (unsupported source, decode failure, no readable frame). Failures here must NOT fail // the whole publish — the orchestrator catches and continues without a poster. - private val uploadPoster: suspend (HlsBlobUploader) -> String? = { _ -> null }, + private val uploadPoster: suspend (HlsBlobUploader) -> HlsPosterUpload? = { _ -> null }, ) { val state: StateFlow = _state @@ -208,7 +228,7 @@ class HlsPublishOrchestrator( // previews) have a still to render. Tolerate failure: skip the poster rather than // failing the entire publish, since the user has already paid the cost of the long // segment uploads. - val posterUrl = + val posterResult = try { uploadPoster(uploader) } catch (e: CancellationException) { @@ -219,7 +239,7 @@ class HlsPublishOrchestrator( } _state.value = HlsPublishState.Publishing - val template = + val built = HlsVideoEventBuilder.build( HlsVideoPublishInput( renditions = uploadResult.renditions, @@ -230,10 +250,23 @@ class HlsPublishOrchestrator( description = request.description, durationSeconds = request.durationSeconds, contentWarning = contentWarningOrNull(request), - posterUrl = posterUrl, + posterUrl = posterResult?.url, + blurhash = posterResult?.blurhash, + thumbhash = posterResult?.thumbhash, ), ) - val eventId = signAndPublish(template) + val sibling = + HlsKind1SiblingBuilder.build( + title = request.title, + description = request.description, + masterUrl = masterUrl, + masterSha256 = masterUpload.sha256, + masterDimension = built.masterDimension, + posterUrl = posterResult?.url, + blurhash = posterResult?.blurhash, + thumbhash = posterResult?.thumbhash, + ) + val eventId = signAndPublish(built.template, sibling) _state.value = HlsPublishState.Success( 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 6444812460..d84708ff46 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 @@ -28,11 +28,13 @@ import com.davotoula.lightcompressor.hls.HlsContentTypes import com.davotoula.lightcompressor.hls.HlsUploadHelper import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult +import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator import com.vitorpamplona.amethyst.service.uploads.getThumbnail import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploader import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploaderFactory import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext @@ -87,7 +89,7 @@ fun createProductionHlsPublishOrchestrator( } } }, - signAndPublish = { template -> + signAndPublish = { template, sibling -> val inner = when (template) { is HlsVideoEventTemplate.Horizontal -> template.template @@ -95,6 +97,17 @@ fun createProductionHlsPublishOrchestrator( } val signed = account.signer.sign(inner) account.sendAutomatic(signed) + + // Sibling-publish failure is a soft warning: the NIP-71 event already landed, so a + // partial success is more useful than a hard fail. + try { + val signedSibling = account.signer.sign(sibling) + account.sendAutomatic(signedSibling) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Log.w(TAG) { "kind:1 sibling sign/publish failed: ${e.message}" } + } signed.id }, uploadPoster = { uploader -> @@ -108,8 +121,9 @@ fun createProductionHlsPublishOrchestrator( ) /** - * Extracts a still frame from the source video at [uri], encodes it as JPEG, and uploads it - * via [uploader]. Returns the public URL on success or null if any step fails (unsupported + * Extracts a still frame from the source video at [uri], encodes it as JPEG, computes blurhash + + * thumbhash from the same JPEG bytes, and uploads it via [uploader]. Returns an [HlsPosterUpload] + * carrying the public URL plus the two hashes on success, or null if any step fails (unsupported * source, no readable frame, encode/upload error). The orchestrator treats null as "publish * without a poster" — failure here must never abort the publish. */ @@ -117,11 +131,28 @@ private suspend fun generateAndUploadPoster( context: Context, uri: Uri, uploader: HlsBlobUploader, -): String? { +): HlsPosterUpload? { val posterFile = extractPosterToTempFile(context, uri) ?: return null return try { + // Read once: hash from the same bytes we upload, so the hashes describe the exact + // JPEG receiving clients will fetch via the imeta `image` URL. + val posterBytes = posterFile.readBytes() + val hashes = + try { + PreviewMetadataCalculator.computeFromBytes(posterBytes, POSTER_CONTENT_TYPE, null) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG) { "uploadPoster: blurhash/thumbhash compute failed: ${e.message}" } + null + } val result = uploader.upload(posterFile, POSTER_CONTENT_TYPE) { _, _ -> } - result.url + val url = result.url ?: return null + HlsPosterUpload( + url = url, + blurhash = hashes?.blurhash?.blurhash, + thumbhash = hashes?.thumbhash?.thumbhash, + ) } finally { if (!posterFile.delete()) { Log.w(TAG) { "uploadPoster: failed to delete temp file ${posterFile.absolutePath}" } @@ -155,6 +186,8 @@ private suspend fun extractPosterToTempFile( } throw e } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.w(TAG) { "extractPosterToTempFile: failed for $uri — ${e.message}" } null 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 38cc496ac5..36efa974d0 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 @@ -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.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 @@ -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)) // Server picker — reads the user's configured Blossom servers from the account @@ -686,54 +660,18 @@ private fun SuccessBody( ) Spacer(Modifier.height(16.dp)) - if (vm.draftNoteAfterUpload) { - Button( - onClick = { - val draft = buildDraftNoteText(vm.title, vm.description, state.masterUrl) - vm.reset() - // Pop the HLS publish screen off the back stack as we open the composer, - // 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. - 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)) - } + 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 private fun FailureBody( vm: NewHlsVideoViewModel, 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 eaccaa3a4b..f5115a3feb 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 @@ -64,7 +64,6 @@ open class NewHlsVideoViewModel : ViewModel() { var sensitiveContent by mutableStateOf(false) var contentWarningReason by mutableStateOf("") var useH265 by mutableStateOf(true) - var draftNoteAfterUpload by mutableStateOf(true) var selectedServer by mutableStateOf(null) var selectedRenditionLabels by mutableStateOf( diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 519ee1b54b..8491e90a18 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -2295,9 +2295,6 @@ Zobrazit poznámku Hotovo Zkusit znovu - Vytvořit poznámku po nahrání - Otevře editor poznámky předvyplněný titulkem, popisem a odkazem na video, abyste ho mohli upravit před odesláním. - Vytvořit poznámku Akce balíčku Akce seznamu Akce záložky diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 09e1f5f397..98a6c7dde1 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -2275,9 +2275,6 @@ anz der Bedingungen ist erforderlich Notiz anzeigen Fertig Erneut versuchen - Notiz nach Upload entwerfen - Öffnet den Notiz-Editor mit Titel, Beschreibung und Video-Link vorausgefüllt, damit du sie vor dem Posten anpassen kannst. - Notiz entwerfen Paket-Aktionen Listenaktionen Lesezeichen-Aktionen diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index b563ae1ac2..68b104626e 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -2317,9 +2317,6 @@ टीका देखें हो गया पुनः प्रयास करें - टीका सम्पादन आरोहण पश्चात - टीका सम्पादक खोलें शीर्षक विवरण तथा दृश्याभिलेख योजक जानकारी से पूर्वयुक्त जिसे आप शोधन कर सकते है प्रकाशन पूर्व। - टीका सम्पादन पोटली कार्य सूची कार्य स्मर्त्तव्यचिह्न कार्य diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 87b7919dc2..a4a5b3217d 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -2316,9 +2316,6 @@ Bejegyzés megtekintése Kész Próbálja újra - Bejegyzéspiszkozat feltöltés után - Bejegyzésszerkesztő megnyitása a címmel, leírással és videóhivatkozásokkal előre kitöltve, hogy a közzététel előtt finomíthasson rajta. - Bejegyzéspiszkozat Csomagműveletek Listaműveletek Könyvjelző-műveletek diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 7972ac2900..766103a309 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -2084,9 +2084,6 @@ Bericht bekijken Klaar Opnieuw proberen - Conceptnotitie na upload - Open de notitie-editor met titel, omschrijving en videolink voorgeladen zodat je het kunt aanpassen voordat je plaatst. - Conceptnotitie Pakket-acties Lijst-acties Bladwijzer-acties diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index ecb95b9a09..580a85f444 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -2339,9 +2339,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Zobacz publikację Gotowe Spróbuj ponownie - Szkic publikacji po wgraniu - Otwórz edytor publikacji z gotowym tytułem, opisem i linkiem do filmu, abyś mógł wprowadzić zmiany przed opublikowaniem. - Szkic publikacji Akcje pakietu Akcje listy Akcje zakładki diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index db193aae04..8720e329e3 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -2271,9 +2271,6 @@ Ver nota Concluído Tentar novamente - Rascunho de nota após upload - Abre o compositor de nota pré-preenchido com o título, descrição e link do vídeo para que você possa ajustá-lo antes de publicar. - Rascunhar nota Ações do pacote Ações da lista Ações de favorito diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index f38e9009bf..47d74405c4 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -2243,9 +2243,6 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Poglej zapisek Končano Poskusi znova - Osnutek zapiska po nalaganju - Odpri urejevalnik z že izpolnjenim naslovom, opisom in povezavo do videa, da ga lahko pred objavo še dodelaš. - Osnutek zapiska Možnosti paketa Možnosti seznama Možnosti zaznamkov diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 3a1f9a1dc4..22eb6e5f24 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -2270,9 +2270,6 @@ Visa anteckning Klar Försök igen - Utkast till anteckning efter uppladdning - Öppnar anteckningskompositören förifylld med titel, beskrivning och videolänk så att du kan justera den innan publicering. - Utkast till anteckning Paketåtgärder Liståtgärder Bokmärkesåtgärder diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index ee1ceb8d67..362856bf40 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -2303,9 +2303,6 @@ 查看笔记 完成 重试 - 上传后的笔记草稿 - 打开预填了标题、描述和视频链接的笔记,以便您可以在发布之前调整它。 - 起草笔记 包操作 列表操作 书签操作 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 009f3e25c6..c5d0721673 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2586,9 +2586,6 @@ View note Done Try again - Draft note after upload - Open the note composer pre-filled with the title, description and video link so you can tweak it before posting. - Draft note Pack Actions List Actions diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsKind1SiblingBuilderTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsKind1SiblingBuilderTest.kt new file mode 100644 index 0000000000..4662481521 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsKind1SiblingBuilderTest.kt @@ -0,0 +1,150 @@ +/* + * 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.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class HlsKind1SiblingBuilderTest { + private val masterUrl = "https://cdn.test/master.m3u8" + private val masterSha = "ffeeddccbbaa00112233445566778899aabbccddeeff00112233445566778899" + private val posterUrl = "https://cdn.test/poster.jpg" + private val blurhash = "LFE.@D9F01_2~q%2tRj[" + private val thumbhash = "wJlGAA" + private val masterDim = DimensionTag(2160, 3840) + + private fun build( + title: String = "My HD video", + description: String = "A clip", + masterSha256: String? = masterSha, + masterDimension: DimensionTag? = masterDim, + posterUrlArg: String? = posterUrl, + blurhashArg: String? = blurhash, + thumbhashArg: String? = thumbhash, + createdAt: Long? = 1_700_000_000L, + ) = HlsKind1SiblingBuilder.build( + title = title, + description = description, + masterUrl = masterUrl, + masterSha256 = masterSha256, + masterDimension = masterDimension, + posterUrl = posterUrlArg, + blurhash = blurhashArg, + thumbhash = thumbhashArg, + createdAt = createdAt, + ) + + private fun Array>.findTag(name: String): Array? = firstOrNull { it.isNotEmpty() && it[0] == name } + + @Test + fun kindIs1() { + val template = build() + assertEquals(TextNoteEvent.KIND, template.kind) + } + + @Test + fun contentJoinsTitleDescriptionMasterUrlOnDoubleNewline() { + val template = build(title = "T", description = "D") + assertEquals("T\n\nD\n\n$masterUrl", template.content) + } + + @Test + fun blankFieldsAreOmittedFromContent() { + val template = build(title = "", description = " ") + assertEquals(masterUrl, template.content) + } + + @Test + fun fieldsAreTrimmed() { + val template = build(title = " T ", description = "\nD\n") + assertEquals("T\n\nD\n\n$masterUrl", template.content) + } + + @Test + fun masterImetaCarriesAllVisualFields() { + val template = build() + val imeta = template.tags.findTag("imeta") + assertNotNull(imeta) + val flat = imeta!!.joinToString("|") + assertTrue("url: $flat", flat.contains("url $masterUrl")) + assertTrue("mime: $flat", flat.contains("m application/vnd.apple.mpegurl")) + assertTrue("hash: $flat", flat.contains("x $masterSha")) + assertTrue("dim: $flat", flat.contains("dim 2160x3840")) + assertTrue("image: $flat", flat.contains("image $posterUrl")) + assertTrue("blurhash: $flat", flat.contains("blurhash $blurhash")) + assertTrue("thumbhash: $flat", flat.contains("thumbhash $thumbhash")) + } + + @Test + fun nullVisualFieldsAreOmittedFromImeta() { + val template = + build( + masterSha256 = null, + masterDimension = null, + posterUrlArg = null, + blurhashArg = null, + thumbhashArg = null, + ) + val imeta = template.tags.findTag("imeta")!! + val flat = imeta.joinToString("|") + assertTrue("url is required: $flat", flat.contains("url $masterUrl")) + assertTrue("mime is always present: $flat", flat.contains("m application/vnd.apple.mpegurl")) + assertFalse("no hash: $flat", flat.contains("x ")) + assertFalse("no dim: $flat", flat.contains("dim ")) + assertFalse("no image: $flat", flat.contains("image ")) + assertFalse("no blurhash: $flat", flat.contains("blurhash ")) + assertFalse("no thumbhash: $flat", flat.contains("thumbhash ")) + } + + @Test + fun rTagCarriesMasterUrl() { + val template = build() + val ref = template.tags.findTag("r") + assertNotNull(ref) + assertEquals(masterUrl, ref!![1]) + } + + @Test + fun noATagIsEmitted() { + val template = build() + assertNull(template.tags.findTag("a")) + } + + @Test + fun createdAtUsesProvidedValue() { + val template = build(createdAt = 12_345L) + assertEquals(12_345L, template.createdAt) + } + + @Test + fun createdAtDefaultsToNowWhenNull() { + val before = System.currentTimeMillis() / 1_000 + val template = build(createdAt = null) + val after = System.currentTimeMillis() / 1_000 + assertTrue(template.createdAt in before..after) + } +} 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 ce0b9715a1..7bd19c175b 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 @@ -30,9 +30,12 @@ import com.davotoula.lightcompressor.hls.Rendition import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPosterUpload import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishOrchestrator import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishRequest import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishState +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking import org.junit.After @@ -160,7 +163,7 @@ class HlsPublishOrchestratorTest { runUpload = fakeRunUpload(), buildUploader = { canned }, uploadMaster = fakeUploadMaster(canned), - signAndPublish = { tpl -> + signAndPublish = { tpl, _ -> publishedTemplates += tpl "signed-event-id" }, @@ -220,7 +223,7 @@ class HlsPublishOrchestratorTest { runUpload = capturingRunUpload, buildUploader = { canned }, uploadMaster = fakeUploadMaster(canned), - signAndPublish = { + signAndPublish = { _, _ -> capturedDuringPublish += orchestrator.state.value "event-id" }, @@ -246,7 +249,7 @@ class HlsPublishOrchestratorTest { runUpload = { _, _, _ -> throw RuntimeException("decode failed") }, buildUploader = { CannedUploader() }, uploadMaster = { _, _ -> MediaUploadResult(url = "never") }, - signAndPublish = { "never" }, + signAndPublish = { _, _ -> "never" }, ) runBlocking { orchestrator.publish(newRequest()) } @@ -266,7 +269,7 @@ class HlsPublishOrchestratorTest { HlsBlobUploader { _, _, _ -> throw RuntimeException("server 500") } }, uploadMaster = { _, _ -> MediaUploadResult(url = "never") }, - signAndPublish = { "never" }, + signAndPublish = { _, _ -> "never" }, ) runBlocking { orchestrator.publish(newRequest()) } @@ -285,7 +288,7 @@ class HlsPublishOrchestratorTest { runUpload = fakeRunUpload(), buildUploader = { canned }, uploadMaster = { _, _ -> throw RuntimeException("master upload failed") }, - signAndPublish = { "never" }, + signAndPublish = { _, _ -> "never" }, ) runBlocking { orchestrator.publish(newRequest()) } @@ -304,7 +307,7 @@ class HlsPublishOrchestratorTest { runUpload = fakeRunUpload(), buildUploader = { canned }, uploadMaster = fakeUploadMaster(canned), - signAndPublish = { throw RuntimeException("relay rejected") }, + signAndPublish = { _, _ -> throw RuntimeException("relay rejected") }, ) runBlocking { orchestrator.publish(newRequest()) } @@ -324,7 +327,7 @@ class HlsPublishOrchestratorTest { runUpload = fakeRunUpload(), buildUploader = { canned }, uploadMaster = fakeUploadMaster(canned), - signAndPublish = { tpl -> + signAndPublish = { tpl, _ -> captured += tpl "event-id" }, @@ -362,7 +365,7 @@ class HlsPublishOrchestratorTest { runUpload = fakeRunUpload(portrait), buildUploader = { canned }, uploadMaster = fakeUploadMaster(canned), - signAndPublish = { tpl -> + signAndPublish = { tpl, _ -> captured += tpl "event-id" }, @@ -376,6 +379,7 @@ class HlsPublishOrchestratorTest { @Test fun posterUrlFromUploadPosterClosureLandsOnEveryImeta() { val captured = mutableListOf() + val capturedSiblings = mutableListOf>() val canned = CannedUploader() val orchestrator = HlsPublishOrchestrator( @@ -383,11 +387,12 @@ class HlsPublishOrchestratorTest { runUpload = fakeRunUpload(), buildUploader = { canned }, uploadMaster = fakeUploadMaster(canned), - signAndPublish = { tpl -> + signAndPublish = { tpl, sibling -> captured += tpl + capturedSiblings += sibling "event-id" }, - uploadPoster = { _ -> "https://cdn.test/poster.jpg" }, + uploadPoster = { _ -> HlsPosterUpload("https://cdn.test/poster.jpg") }, ) runBlocking { orchestrator.publish(newRequest()) } @@ -399,6 +404,13 @@ class HlsPublishOrchestratorTest { val flat = imeta.joinToString("|") assertTrue("imeta missing poster: $flat", flat.contains("image https://cdn.test/poster.jpg")) } + + // Sibling kind:1 carries the same poster URL on its single imeta. + val sibling = capturedSiblings.single() + val siblingImeta = sibling.tags.firstOrNull { it.isNotEmpty() && it[0] == "imeta" } + assertNotNull("sibling missing imeta", siblingImeta) + val siblingFlat = siblingImeta!!.joinToString("|") + assertTrue("sibling imeta missing poster: $siblingFlat", siblingFlat.contains("image https://cdn.test/poster.jpg")) } @Test @@ -411,7 +423,7 @@ class HlsPublishOrchestratorTest { runUpload = fakeRunUpload(), buildUploader = { canned }, uploadMaster = fakeUploadMaster(canned), - signAndPublish = { tpl -> + signAndPublish = { tpl, _ -> captured += tpl "event-id" }, @@ -439,7 +451,7 @@ class HlsPublishOrchestratorTest { runUpload = { _, _, _ -> throw RuntimeException("boom") }, buildUploader = { CannedUploader() }, uploadMaster = { _, _ -> MediaUploadResult(url = "never") }, - signAndPublish = { "never" }, + signAndPublish = { _, _ -> "never" }, ) runBlocking { orchestrator.publish(newRequest()) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt index f21f6b63bb..84e6c042a7 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt @@ -120,8 +120,8 @@ class HlsVideoEventBuilderTest { fun landscapeRenditionsBuildHorizontalTemplateKind34235() { val result = HlsVideoEventBuilder.build(input(landscapeRenditions)) - assertTrue("expected Horizontal template", result is HlsVideoEventTemplate.Horizontal) - val template = (result as HlsVideoEventTemplate.Horizontal).template + assertTrue("expected Horizontal template", result.template is HlsVideoEventTemplate.Horizontal) + val template = (result.template as HlsVideoEventTemplate.Horizontal).template assertEquals(VideoHorizontalEvent.KIND, template.kind) assertEquals("A cool video", template.content) } @@ -130,15 +130,15 @@ class HlsVideoEventBuilderTest { fun portraitRenditionsBuildVerticalTemplateKind34236() { val result = HlsVideoEventBuilder.build(input(portraitRenditions)) - assertTrue("expected Vertical template", result is HlsVideoEventTemplate.Vertical) - val template = (result as HlsVideoEventTemplate.Vertical).template + assertTrue("expected Vertical template", result.template is HlsVideoEventTemplate.Vertical) + val template = (result.template as HlsVideoEventTemplate.Vertical).template assertEquals(VideoVerticalEvent.KIND, template.kind) } @Test fun horizontalTemplateHasTitleAndDTag() { 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") assertNotNull(title) @@ -152,7 +152,7 @@ class HlsVideoEventBuilderTest { @Test fun templateContainsOneImetaForMasterAndOnePerRendition() { 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") // 1 master + 2 renditions @@ -181,7 +181,7 @@ class HlsVideoEventBuilderTest { HlsVideoEventBuilder.build( 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") assertNotNull(duration) @@ -191,7 +191,7 @@ class HlsVideoEventBuilderTest { @Test fun noDurationTagWhenNotProvided() { 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")) } @@ -201,7 +201,7 @@ class HlsVideoEventBuilderTest { HlsVideoEventBuilder.build( 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") assertNotNull(warning) @@ -211,14 +211,14 @@ class HlsVideoEventBuilderTest { @Test fun noContentWarningTagWhenNull() { 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")) } @Test fun horizontalTemplateCarriesAutoGeneratedAltTag() { 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") assertNotNull(alt) assertEquals(VideoHorizontalEvent.ALT_DESCRIPTION, alt!![1]) @@ -227,7 +227,7 @@ class HlsVideoEventBuilderTest { @Test fun verticalTemplateCarriesVerticalAltTag() { 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") assertNotNull(alt) assertEquals(VideoVerticalEvent.ALT_DESCRIPTION, alt!![1]) @@ -239,7 +239,7 @@ class HlsVideoEventBuilderTest { HlsVideoEventBuilder.build( 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") // 1 master + 2 renditions, each must carry image @@ -253,7 +253,7 @@ class HlsVideoEventBuilderTest { @Test fun noPosterUrlMeansNoImagePropertyOnImetas() { 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") imetas.forEach { imeta -> diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 0353bdc1ea..e3cb263137 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip92IMeta.imetasByUrl import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ImageTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.utils.Log @@ -64,7 +65,12 @@ class RichTextParser { if (contentType != null) { isImage = contentType.startsWith("image/") - isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/") + // HLS playlists are advertised with a non-`video/*` MIME (`application/vnd.apple.mpegurl` + // and three legacy aliases). Without these, an imeta-described `.m3u8` falls into the + // null bucket below and the renderer drops back to a plain hyperlink — even though + // the matching extension would have routed it to MediaUrlVideo. Mirror the canonical + // list used by MediaItemCache.toExoPlayerMimeType / GalleryThumb.isHlsMimeType. + isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/") || isHlsMimeType(contentType) isPdf = contentType.startsWith("application/pdf") } else if (fullUrl.startsWith("data:")) { isImage = fullUrl.startsWith("data:image/") @@ -99,6 +105,10 @@ class RichTextParser { dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) }, contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(), uri = callbackUri, + // Poster URL from the imeta's `image` property — downstream gallery-add reads + // this as the entry's `image` tag so the gallery thumbnail can render the + // poster JPEG instead of falling back to the blurhash placeholder. + artworkUri = frags[ImageTag.TAG_NAME] ?: tags[ImageTag.TAG_NAME]?.firstOrNull(), mimeType = contentType, thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(), authorPubKey = authorPubKey, @@ -457,6 +467,17 @@ class RichTextParser { return videoExtensions.any { removedParamsFromUrl.endsWith(it) } } + // Mirrors the canonical HLS-playlist MIME list also kept in MediaItemCache.toExoPlayerMimeType. + // Called per URL during feed render — uses `equals(ignoreCase)` instead of `lowercase()` to + // avoid a per-call String allocation on the common non-HLS path. + fun isHlsMimeType(mimeType: String?): Boolean { + if (mimeType == null) return false + return mimeType.equals("application/vnd.apple.mpegurl", ignoreCase = true) || + mimeType.equals("application/x-mpegurl", ignoreCase = true) || + mimeType.equals("audio/x-mpegurl", ignoreCase = true) || + mimeType.equals("audio/mpegurl", ignoreCase = true) + } + fun isPdfUrl(url: String): Boolean { val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) return pdfExtensions.any { removedParamsFromUrl.endsWith(it) }