Merge pull request #2821 from davotoula/fix/hls-imeta-blurhash-thumbhash

Rich imeta on every published HLS event + auto-published kind:1 sibling
This commit is contained in:
Vitor Pamplona
2026-05-10 14:22:01 -04:00
committed by GitHub
22 changed files with 427 additions and 176 deletions
@@ -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<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) }
blurhash?.let { this.blurhash(it) }
thumbhash?.let { this.thumbhash(it) }
}
return TextNoteEvent.build(content, createdAt ?: TimeUtils.now()) {
imeta(masterImeta)
reference(masterUrl)
}
}
}
@@ -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)
}
}
@@ -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,
@@ -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<MediaUploadResult>,
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<TextNoteEvent>,
) -> 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<HlsPublishState> = _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(
@@ -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
@@ -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,
@@ -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<ServerName?>(null)
var selectedRenditionLabels by mutableStateOf(
@@ -2295,9 +2295,6 @@
<string name="hls_view_note">Zobrazit poznámku</string>
<string name="hls_done">Hotovo</string>
<string name="hls_try_again">Zkusit znovu</string>
<string name="hls_draft_note_after_upload">Vytvořit poznámku po nahrání</string>
<string name="hls_draft_note_after_upload_explainer">Otevře editor poznámky předvyplněný titulkem, popisem a odkazem na video, abyste ho mohli upravit před odesláním.</string>
<string name="hls_draft_note_button">Vytvořit poznámku</string>
<string name="pack_actions_dialog_title">Akce balíčku</string>
<string name="list_actions_dialog_title">Akce seznamu</string>
<string name="bookmark_item_actions_dialog_title">Akce záložky</string>
@@ -2275,9 +2275,6 @@ anz der Bedingungen ist erforderlich</string>
<string name="hls_view_note">Notiz anzeigen</string>
<string name="hls_done">Fertig</string>
<string name="hls_try_again">Erneut versuchen</string>
<string name="hls_draft_note_after_upload">Notiz nach Upload entwerfen</string>
<string name="hls_draft_note_after_upload_explainer">Öffnet den Notiz-Editor mit Titel, Beschreibung und Video-Link vorausgefüllt, damit du sie vor dem Posten anpassen kannst.</string>
<string name="hls_draft_note_button">Notiz entwerfen</string>
<string name="pack_actions_dialog_title">Paket-Aktionen</string>
<string name="list_actions_dialog_title">Listenaktionen</string>
<string name="bookmark_item_actions_dialog_title">Lesezeichen-Aktionen</string>
@@ -2317,9 +2317,6 @@
<string name="hls_view_note">टीका देखें</string>
<string name="hls_done">हो गया</string>
<string name="hls_try_again">पुनः प्रयास करें</string>
<string name="hls_draft_note_after_upload">टीका सम्पादन आरोहण पश्चात</string>
<string name="hls_draft_note_after_upload_explainer">टीका सम्पादक खोलें शीर्षक विवरण तथा दृश्याभिलेख योजक जानकारी से पूर्वयुक्त जिसे आप शोधन कर सकते है प्रकाशन पूर्व।</string>
<string name="hls_draft_note_button">टीका सम्पादन</string>
<string name="pack_actions_dialog_title">पोटली कार्य</string>
<string name="list_actions_dialog_title">सूची कार्य</string>
<string name="bookmark_item_actions_dialog_title">स्मर्त्तव्यचिह्न कार्य</string>
@@ -2316,9 +2316,6 @@
<string name="hls_view_note">Bejegyzés megtekintése</string>
<string name="hls_done">Kész</string>
<string name="hls_try_again">Próbálja újra</string>
<string name="hls_draft_note_after_upload">Bejegyzéspiszkozat feltöltés után</string>
<string name="hls_draft_note_after_upload_explainer">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.</string>
<string name="hls_draft_note_button">Bejegyzéspiszkozat</string>
<string name="pack_actions_dialog_title">Csomagműveletek</string>
<string name="list_actions_dialog_title">Listaműveletek</string>
<string name="bookmark_item_actions_dialog_title">Könyvjelző-műveletek</string>
@@ -2084,9 +2084,6 @@
<string name="hls_view_note">Bericht bekijken</string>
<string name="hls_done">Klaar</string>
<string name="hls_try_again">Opnieuw proberen</string>
<string name="hls_draft_note_after_upload">Conceptnotitie na upload</string>
<string name="hls_draft_note_after_upload_explainer">Open de notitie-editor met titel, omschrijving en videolink voorgeladen zodat je het kunt aanpassen voordat je plaatst.</string>
<string name="hls_draft_note_button">Conceptnotitie</string>
<string name="pack_actions_dialog_title">Pakket-acties</string>
<string name="list_actions_dialog_title">Lijst-acties</string>
<string name="bookmark_item_actions_dialog_title">Bladwijzer-acties</string>
@@ -2339,9 +2339,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="hls_view_note">Zobacz publikację</string>
<string name="hls_done">Gotowe</string>
<string name="hls_try_again">Spróbuj ponownie</string>
<string name="hls_draft_note_after_upload">Szkic publikacji po wgraniu</string>
<string name="hls_draft_note_after_upload_explainer">Otwórz edytor publikacji z gotowym tytułem, opisem i linkiem do filmu, abyś mógł wprowadzić zmiany przed opublikowaniem.</string>
<string name="hls_draft_note_button">Szkic publikacji</string>
<string name="pack_actions_dialog_title">Akcje pakietu</string>
<string name="list_actions_dialog_title">Akcje listy</string>
<string name="bookmark_item_actions_dialog_title">Akcje zakładki</string>
@@ -2271,9 +2271,6 @@
<string name="hls_view_note">Ver nota</string>
<string name="hls_done">Concluído</string>
<string name="hls_try_again">Tentar novamente</string>
<string name="hls_draft_note_after_upload">Rascunho de nota após upload</string>
<string name="hls_draft_note_after_upload_explainer">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.</string>
<string name="hls_draft_note_button">Rascunhar nota</string>
<string name="pack_actions_dialog_title">Ações do pacote</string>
<string name="list_actions_dialog_title">Ações da lista</string>
<string name="bookmark_item_actions_dialog_title">Ações de favorito</string>
@@ -2243,9 +2243,6 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="hls_view_note">Poglej zapisek</string>
<string name="hls_done">Končano</string>
<string name="hls_try_again">Poskusi znova</string>
<string name="hls_draft_note_after_upload">Osnutek zapiska po nalaganju</string>
<string name="hls_draft_note_after_upload_explainer">Odpri urejevalnik z že izpolnjenim naslovom, opisom in povezavo do videa, da ga lahko pred objavo še dodelaš.</string>
<string name="hls_draft_note_button">Osnutek zapiska</string>
<string name="pack_actions_dialog_title">Možnosti paketa</string>
<string name="list_actions_dialog_title">Možnosti seznama</string>
<string name="bookmark_item_actions_dialog_title">Možnosti zaznamkov</string>
@@ -2270,9 +2270,6 @@
<string name="hls_view_note">Visa anteckning</string>
<string name="hls_done">Klar</string>
<string name="hls_try_again">Försök igen</string>
<string name="hls_draft_note_after_upload">Utkast till anteckning efter uppladdning</string>
<string name="hls_draft_note_after_upload_explainer">Öppnar anteckningskompositören förifylld med titel, beskrivning och videolänk så att du kan justera den innan publicering.</string>
<string name="hls_draft_note_button">Utkast till anteckning</string>
<string name="pack_actions_dialog_title">Paketåtgärder</string>
<string name="list_actions_dialog_title">Liståtgärder</string>
<string name="bookmark_item_actions_dialog_title">Bokmärkesåtgärder</string>
@@ -2303,9 +2303,6 @@
<string name="hls_view_note">查看笔记</string>
<string name="hls_done">完成</string>
<string name="hls_try_again">重试</string>
<string name="hls_draft_note_after_upload">上传后的笔记草稿</string>
<string name="hls_draft_note_after_upload_explainer">打开预填了标题、描述和视频链接的笔记,以便您可以在发布之前调整它。</string>
<string name="hls_draft_note_button">起草笔记</string>
<string name="pack_actions_dialog_title">包操作</string>
<string name="list_actions_dialog_title">列表操作</string>
<string name="bookmark_item_actions_dialog_title">书签操作</string>
-3
View File
@@ -2586,9 +2586,6 @@
<string name="hls_view_note">View note</string>
<string name="hls_done">Done</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="list_actions_dialog_title">List Actions</string>
@@ -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<Array<String>>.findTag(name: String): Array<String>? = 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)
}
}
@@ -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<HlsVideoEventTemplate>()
val capturedSiblings = mutableListOf<EventTemplate<TextNoteEvent>>()
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()) }
@@ -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 <posterUrl>
@@ -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 ->
@@ -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) }