Merge pull request #2401 from davotoula/hls-video-integration

Share HLS Video via NIP-71 + LightCompressor-enhanced 2.2.0
This commit is contained in:
Vitor Pamplona
2026-04-15 11:09:04 -04:00
committed by GitHub
18 changed files with 2322 additions and 13 deletions
@@ -25,10 +25,17 @@ import androidx.media3.common.Tracks
fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.length > 0 }
fun getCurrentPlayingHeight(tracks: Tracks): Int? {
// Returns the "Xp" value for the currently selected video track. Uses min(width, height) so
// that a portrait video's renditions get the same "360p / 540p / 720p" labels as a landscape
// source — the streaming convention is to label by the short side, not format.height which is
// the long side for portrait content.
fun getCurrentPlayingShortSide(tracks: Tracks): Int? {
val group = getVideoTrackGroup(tracks) ?: return null
for (i in 0 until group.length) {
if (group.isTrackSelected(i)) return group.getTrackFormat(i).height
if (group.isTrackSelected(i)) {
val format = group.getTrackFormat(i)
return minOf(format.width, format.height).takeIf { it > 0 }
}
}
return null
}
@@ -124,7 +124,7 @@ fun VideoQualityButton(
) {
VideoQualityChoices(
videoGroup = videoGroup,
currentHeight = getCurrentPlayingHeight(tracks),
currentShortSide = getCurrentPlayingShortSide(tracks),
isAuto = !hasVideoOverride(player),
onSelectAuto = {
clearVideoOverride(player)
@@ -142,7 +142,7 @@ fun VideoQualityButton(
@Composable
private fun VideoQualityChoices(
videoGroup: Tracks.Group,
currentHeight: Int?,
currentShortSide: Int?,
isAuto: Boolean,
onSelectAuto: () -> Unit,
onSelectTrack: (Int) -> Unit,
@@ -162,7 +162,7 @@ private fun VideoQualityChoices(
horizontalAlignment = Alignment.CenterHorizontally,
) {
TextButton(colors = colors, onClick = onSelectAuto) {
val suffix = currentHeight?.let { " (${it}p)" } ?: ""
val suffix = currentShortSide?.let { " (${it}p)" } ?: ""
Text(
stringRes(R.string.video_quality_auto) + suffix,
fontWeight = if (isAuto) FontWeight(1000) else FontWeight(400),
@@ -172,17 +172,20 @@ private fun VideoQualityChoices(
choices.forEach { choice ->
TextButton(colors = colors, onClick = { onSelectTrack(choice.trackIndex) }) {
Text(
"${choice.height}p ${formatBitrate(choice.bitrate)}",
fontWeight = if (!isAuto && currentHeight == choice.height) FontWeight(1000) else FontWeight(400),
"${choice.shortSide}p ${formatBitrate(choice.bitrate)}",
fontWeight = if (!isAuto && currentShortSide == choice.shortSide) FontWeight(1000) else FontWeight(400),
)
}
}
}
}
// shortSide = min(width, height). Matches the streaming convention that "360p" means
// 360 pixels on the short side regardless of orientation, so portrait videos get sensible
// labels instead of "640p / 960p / 1280p" for the same ladder rungs.
private data class QualityChoice(
val trackIndex: Int,
val height: Int,
val shortSide: Int,
val bitrate: Int,
)
@@ -190,11 +193,12 @@ private fun buildQualityChoices(group: Tracks.Group): ImmutableList<QualityChoic
val choices = mutableListOf<QualityChoice>()
for (i in 0 until group.length) {
val format = group.getTrackFormat(i)
if (format.height > 0) {
choices.add(QualityChoice(i, format.height, format.bitrate))
val shortSide = minOf(format.width, format.height)
if (shortSide > 0) {
choices.add(QualityChoice(i, shortSide, format.bitrate))
}
}
return choices.sortedByDescending { it.height }.toImmutableList()
return choices.sortedByDescending { it.shortSide }.toImmutableList()
}
private fun formatBitrate(bitrate: Int): String =
@@ -0,0 +1,41 @@
/*
* 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.amethyst.service.uploads.MediaUploadResult
import java.io.File
/**
* Abstraction over a blob upload transport so the HLS publish orchestrator can stay
* unit-testable. Production wiring adapts this to either
* [com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader] or
* [com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader]. The HLS orchestrator
* wraps this in a String-returning lambda for
* [com.davotoula.lightcompressor.hls.HlsUploadHelper.run] and captures each
* [MediaUploadResult] in a side-channel map keyed by the library's suggested filename so the
* per-rendition sha256/size can flow into the NIP-71 event's imeta tags.
*/
fun interface HlsBlobUploader {
suspend fun upload(
file: File,
contentType: String,
): MediaUploadResult
}
@@ -0,0 +1,117 @@
/*
* 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 android.content.Context
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
/**
* Turns a user-chosen [ServerName] into an [HlsBlobUploader] by adapting the concrete
* [Nip96Uploader] / [BlossomUploader] to the simpler file+contentType interface the HLS upload
* pipeline uses. Keeps the pipeline free of direct Amethyst/account wiring so it stays
* unit-testable.
*
* HLS uploads get a dedicated OkHttp client derived from the shared upload client: write and
* read timeouts are disabled so a slow rendition trickling through at a few hundred KB/s is
* not killed mid-stream, and server-side hashing/scanning that blocks the response for minutes
* does not fire the read timeout while the request is still in flight. A generous per-call
* timeout remains in place as a hard cap so a silently dead connection eventually errors out.
*/
object HlsBlobUploaderFactory {
private const val CALL_TIMEOUT_MINUTES = 15L
private fun okHttpClientForHlsUploads(serverBaseUrl: String): OkHttpClient =
Amethyst.instance.roleBasedHttpClientBuilder
.okHttpClientForUploads(serverBaseUrl)
.newBuilder()
.writeTimeout(0, TimeUnit.MILLISECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.callTimeout(CALL_TIMEOUT_MINUTES, TimeUnit.MINUTES)
.build()
fun create(
server: ServerName,
account: Account,
context: Context,
): HlsBlobUploader =
when (server.type) {
ServerType.Blossom -> {
blossomAdapter(server.baseUrl, account, context)
}
ServerType.NIP96 -> {
nip96Adapter(server.baseUrl, account, context)
}
ServerType.NIP95 -> {
throw IllegalArgumentException(
"NIP-95 storage stores each blob as an event and is not suitable for HLS renditions",
)
}
}
private fun blossomAdapter(
serverBaseUrl: String,
account: Account,
context: Context,
): HlsBlobUploader =
HlsBlobUploader { file, contentType ->
BlossomUploader().upload(
uri = file.toUri(),
contentType = contentType,
size = file.length(),
alt = null,
sensitiveContent = null,
serverBaseUrl = serverBaseUrl,
okHttpClient = ::okHttpClientForHlsUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
private fun nip96Adapter(
serverBaseUrl: String,
account: Account,
context: Context,
): HlsBlobUploader =
HlsBlobUploader { file, contentType ->
Nip96Uploader().upload(
uri = file.toUri(),
contentType = contentType,
size = file.length(),
alt = null,
sensitiveContent = null,
serverBaseUrl = serverBaseUrl,
okHttpClient = ::okHttpClientForHlsUploads,
onProgress = { /* pipeline reports progress per-upload; NIP-96 per-request progress is not forwarded */ },
httpAuth = account::createHTTPAuthorization,
context = context,
)
}
}
@@ -0,0 +1,137 @@
/*
* 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.davotoula.lightcompressor.hls.HlsRenditionSummary
import com.davotoula.lightcompressor.hls.HlsUploaded
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoMeta
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip71Video.duration
import com.vitorpamplona.quartz.nip71Video.title
import com.vitorpamplona.quartz.nip71Video.videoIMetas
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
data class HlsVideoPublishInput(
val renditions: List<HlsRenditionSummary>,
val uploads: Map<String, HlsUploaded<MediaUploadResult>>,
val masterUrl: String,
val masterSha256: String?,
val title: String,
val description: String,
val alt: String? = null,
val durationSeconds: Int? = null,
val contentWarning: String? = null,
val dTag: String? = null,
val createdAt: Long? = null,
)
sealed class HlsVideoEventTemplate {
data class Horizontal(
val template: EventTemplate<VideoHorizontalEvent>,
) : HlsVideoEventTemplate()
data class Vertical(
val template: EventTemplate<VideoVerticalEvent>,
) : HlsVideoEventTemplate()
}
/**
* Assembles a NIP-71 VideoHorizontalEvent / VideoVerticalEvent template from an HLS upload
* result. Orientation is decided from the first rendition's width/height: portrait
* (height > width) selects kind 34236, otherwise 34235.
*
* The template carries one `imeta` tag for the master playlist (primary) plus one per rendition
* so HLS-unaware clients can still pick a specific variant. Every imeta is marked
* `m application/vnd.apple.mpegurl`. The rendition imeta's `x`/`size` come from the combined
* fMP4 upload (single-file layout) while the `url` points at the rewritten media playlist.
*
* Returns the unsigned template wrapped in a sealed [HlsVideoEventTemplate]; the caller signs
* via the account's signer and publishes via the relay client.
*/
@OptIn(ExperimentalUuidApi::class)
object HlsVideoEventBuilder {
fun build(input: HlsVideoPublishInput): HlsVideoEventTemplate {
val firstRendition = input.renditions.firstOrNull()
val isVertical = firstRendition != null && firstRendition.height > firstRendition.width
val largest = input.renditions.maxByOrNull { it.width * it.height }
val masterDimension = largest?.let { DimensionTag(it.width, it.height) }
val masterVideoMeta =
VideoMeta(
url = input.masterUrl,
mimeType = HlsContentTypes.HLS_PLAYLIST,
hash = input.masterSha256,
dimension = masterDimension,
alt = input.alt,
)
val renditionMetas =
input.renditions.map { summary ->
val combinedFilename =
summary.combinedFilename
?: "${summary.rendition.resolution.label}.mp4"
val combinedMetadata = input.uploads[combinedFilename]?.metadata
val playlistUpload =
input.uploads[summary.playlistFilename]
?: error("No upload recorded for media playlist ${summary.playlistFilename}")
VideoMeta(
url = playlistUpload.url,
mimeType = HlsContentTypes.HLS_PLAYLIST,
hash = combinedMetadata?.sha256,
size = combinedMetadata?.size?.toInt(),
dimension = DimensionTag(summary.width, summary.height),
)
}
val videoMetas = listOf(masterVideoMeta) + renditionMetas
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) }
},
)
}
}
}
@@ -144,7 +144,10 @@ class Nip96Uploader {
checkNotInMainThread()
val fileName = RandomInstance.randomChars(16)
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val extension =
contentType?.let {
MimeTypeMap.getSingleton().getExtensionFromMimeType(it) ?: fallbackExtensionForMimeType(it)
} ?: ""
val client = okHttpClient(server.apiUrl)
val requestBuilder = Request.Builder()
@@ -231,6 +234,18 @@ class Nip96Uploader {
fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://")
// Android's MimeTypeMap does not know every MIME we upload (notably HLS playlist types).
// When it returns null we fall back to a small static table so the multipart filename still
// carries a real extension — otherwise the server gets "name." and echoes it back, which
// breaks HLS URL rewriting.
private fun fallbackExtensionForMimeType(mimeType: String): String? =
when (mimeType.lowercase()) {
"application/vnd.apple.mpegurl", "application/x-mpegurl", "audio/x-mpegurl", "audio/mpegurl" -> "m3u8"
"video/mp2t" -> "ts"
"video/iso.segment", "video/mp4" -> "mp4"
else -> null
}
fun convertToMediaResult(nip96: PartialEvent): MediaUploadResult {
// Images don't seem to be ready immediately after upload
val imageUrl = nip96.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
@@ -146,6 +146,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
@@ -221,6 +222,7 @@ fun BuildNavigation(
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
composableFromEnd<Route.Wallet> { WalletScreen(accountViewModel, nav) }
@@ -61,6 +61,7 @@ import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.Photo
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SettingsInputAntenna
import androidx.compose.material.icons.outlined.SmartDisplay
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -607,6 +608,14 @@ fun ListContent(
route = Route.Longs,
)
NavigationRow(
title = R.string.share_hls_video,
icon = Icons.Outlined.SettingsInputAntenna,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.NewHlsVideo,
)
NavigationRow(
title = R.string.wallet,
icon = Icons.Outlined.AccountBalanceWallet,
@@ -410,6 +410,8 @@ sealed class Route {
val draft: String? = null,
) : Route()
@Serializable data object NewHlsVideo : Route()
@Serializable
data class VoiceReply(
val replyToNoteId: String,
@@ -0,0 +1,195 @@
/*
* 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.ui.screen.loggedIn.video.hls
import com.davotoula.lightcompressor.VideoCodec
import com.davotoula.lightcompressor.hls.HlsConfig
import com.davotoula.lightcompressor.hls.HlsContentTypes
import com.davotoula.lightcompressor.hls.HlsLadder
import com.davotoula.lightcompressor.hls.HlsListener
import com.davotoula.lightcompressor.hls.HlsUploadResult
import com.davotoula.lightcompressor.hls.HlsUploaded
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.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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.io.File
data class HlsPublishRequest(
val title: String,
val description: String,
val sensitiveContent: Boolean,
val contentWarningReason: String,
val codec: VideoCodec,
val server: ServerName,
val ladder: HlsLadder = HlsLadder.default(),
val durationSeconds: Int? = 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
* [com.davotoula.lightcompressor.hls.HlsUploadHelper] via the injected [runUpload] closure, then
* uploads the rewritten master playlist itself, builds the NIP-71 event, signs, and publishes.
* All Android/account-specific concerns are injected as suspending callbacks so the whole state
* machine is unit-testable.
*
* State transitions: Idle Transcoding (per rendition, driven by listener) Uploading (per
* segment/playlist upload, driven by the uploader lambda) Publishing Success, or Failure
* on any exception.
*/
class HlsPublishOrchestrator(
private val _state: MutableStateFlow<HlsPublishState>,
private val runUpload: suspend (
config: HlsConfig,
listener: HlsListener,
uploadFile: suspend (File, String) -> HlsUploaded<MediaUploadResult>,
) -> HlsUploadResult<MediaUploadResult>,
private val buildUploader: (ServerName) -> HlsBlobUploader,
private val uploadMaster: suspend (HlsBlobUploader, String) -> MediaUploadResult,
private val signAndPublish: suspend (HlsVideoEventTemplate) -> String,
) {
val state: StateFlow<HlsPublishState> = _state
suspend fun publish(request: HlsPublishRequest) {
try {
_state.value = HlsPublishState.Transcoding(currentLabel = "", percent = 0)
val uploader = buildUploader(request.server)
val config =
HlsConfig(
codec = request.codec,
ladder = request.ladder,
)
val totalUploads = request.ladder.renditions.size * 2 + 1
var uploadsDone = 0
// Dedup Transcoding state emissions so onProgress (which fires many times per
// integer percent) doesn't flood the StateFlow with identical values.
var lastTranscodingLabel: String? = null
var lastTranscodingPercent = -1
val listener =
object : SimpleHlsListener() {
override fun onRenditionStart(rendition: Rendition) {
val label = rendition.resolution.label
if (lastTranscodingLabel != label || lastTranscodingPercent != 0) {
lastTranscodingLabel = label
lastTranscodingPercent = 0
_state.value = HlsPublishState.Transcoding(label, 0)
}
}
override fun onProgress(
rendition: Rendition,
percent: Float,
) {
val label = rendition.resolution.label
val p = percent.toInt()
if (lastTranscodingLabel != label || lastTranscodingPercent != p) {
lastTranscodingLabel = label
lastTranscodingPercent = p
_state.value = HlsPublishState.Transcoding(label, p)
}
}
}
val uploadResult =
runUpload(config, listener) { file, suggestedFilename ->
// Pre-increment: `done` tracks the in-flight index, not the finished
// count. StateFlow conflation would otherwise eat the post-upload tick.
uploadsDone++
_state.value =
HlsPublishState.Uploading(
done = uploadsDone,
total = totalUploads,
currentLabel = suggestedFilename,
)
val contentType =
if (suggestedFilename.endsWith(".m3u8")) {
HlsContentTypes.HLS_PLAYLIST
} else {
HlsContentTypes.FMP4_SEGMENT
}
val result = uploader.upload(file, contentType)
HlsUploaded(
url =
result.url
?: error("Uploader returned null URL for $suggestedFilename"),
metadata = result,
)
}
uploadsDone++
_state.value =
HlsPublishState.Uploading(
done = uploadsDone,
total = totalUploads,
currentLabel = "master.m3u8",
)
val masterUpload = uploadMaster(uploader, uploadResult.masterPlaylist)
val masterUrl =
masterUpload.url ?: error("Uploader returned null URL for master playlist")
_state.value = HlsPublishState.Publishing
val template =
HlsVideoEventBuilder.build(
HlsVideoPublishInput(
renditions = uploadResult.renditions,
uploads = uploadResult.uploads,
masterUrl = masterUrl,
masterSha256 = masterUpload.sha256,
title = request.title,
description = request.description,
durationSeconds = request.durationSeconds,
contentWarning = contentWarningOrNull(request),
),
)
val eventId = signAndPublish(template)
_state.value =
HlsPublishState.Success(
eventId = eventId,
masterUrl = masterUrl,
)
} catch (e: CancellationException) {
// Cancellation is not a failure. Let the rethrow propagate up the coroutine
// scope; NewHlsVideoViewModel.cancel() calls reset() to put state back to Idle.
throw e
} catch (e: Throwable) {
_state.value = HlsPublishState.Failure(message = e.message ?: e::class.simpleName.orEmpty())
}
}
fun reset() {
_state.value = HlsPublishState.Idle
}
private fun contentWarningOrNull(request: HlsPublishRequest): String? = if (request.sensitiveContent) request.contentWarningReason else null
}
@@ -0,0 +1,82 @@
/*
* 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.ui.screen.loggedIn.video.hls
import android.content.Context
import android.net.Uri
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.hls.HlsBlobUploaderFactory
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate
import kotlinx.coroutines.flow.MutableStateFlow
import java.io.File
/**
* Production wiring for [HlsPublishOrchestrator]. Binds the upload closure to
* [HlsUploadHelper.run], the uploader factory to [HlsBlobUploaderFactory], and the
* signAndPublish closure to the account's signer + outbox publish path.
*
* The Uri is read via [uriProvider] on each publish invocation so the orchestrator can be built
* once (at VM load) before the user actually picks a video.
*/
fun createProductionHlsPublishOrchestrator(
state: MutableStateFlow<HlsPublishState>,
account: Account,
context: Context,
uriProvider: () -> Uri?,
): HlsPublishOrchestrator =
HlsPublishOrchestrator(
_state = state,
runUpload = { config, listener, uploadFile ->
val uri = uriProvider() ?: error("No video picked")
HlsUploadHelper.run<MediaUploadResult>(
context = context,
uri = uri,
config = config,
listener = listener,
uploader = uploadFile,
)
},
buildUploader = { server ->
HlsBlobUploaderFactory.create(server, account, context)
},
uploadMaster = { uploader, masterPlaylist ->
val masterFile = File.createTempFile("hls-master-", ".m3u8", context.cacheDir)
try {
masterFile.writeText(masterPlaylist)
uploader.upload(masterFile, HlsContentTypes.HLS_PLAYLIST)
} finally {
masterFile.delete()
}
},
signAndPublish = { template ->
val inner =
when (template) {
is HlsVideoEventTemplate.Horizontal -> template.template
is HlsVideoEventTemplate.Vertical -> template.template
}
val signed = account.signer.sign(inner)
account.sendAutomatic(signed)
signed.id
},
)
@@ -0,0 +1,47 @@
/*
* 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.ui.screen.loggedIn.video.hls
sealed class HlsPublishState {
data object Idle : HlsPublishState()
data class Transcoding(
val currentLabel: String,
val percent: Int,
) : HlsPublishState()
data class Uploading(
val done: Int,
val total: Int,
val currentLabel: String = "",
) : HlsPublishState()
data object Publishing : HlsPublishState()
data class Success(
val eventId: String,
val masterUrl: String,
) : HlsPublishState()
data class Failure(
val message: String,
) : HlsPublishState()
}
@@ -0,0 +1,789 @@
/*
* 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.ui.screen.loggedIn.video.hls
import android.media.MediaMetadataRetriever
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.davotoula.lightcompressor.hls.HlsLadder
import com.davotoula.lightcompressor.utils.CompressorUtils
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewHlsVideoScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val vm: NewHlsVideoViewModel = viewModel()
val context = LocalContext.current
LaunchedEffect(accountViewModel) {
vm.load(accountViewModel.account, context)
}
Scaffold(
topBar = {
TopBarWithBackButton(
caption = stringResource(R.string.share_hls_video),
popBack = nav::popBack,
)
},
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
NewHlsVideoBody(vm, nav)
}
}
}
@Composable
private fun NewHlsVideoBody(
vm: NewHlsVideoViewModel,
nav: INav,
) {
val publishState by vm.state.collectAsState()
when (val state = publishState) {
is HlsPublishState.Idle -> IdleBody(vm)
is HlsPublishState.Transcoding,
is HlsPublishState.Uploading,
is HlsPublishState.Publishing,
-> ProgressBody(vm, state)
is HlsPublishState.Success -> SuccessBody(vm, state, nav)
is HlsPublishState.Failure -> FailureBody(vm, state)
}
}
@Composable
private fun IdleBody(vm: NewHlsVideoViewModel) {
val context = LocalContext.current
val pickLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
vm.onVideoPicked(uri, metadata = null)
}
// Probe source metadata in the background whenever pickedUri flips to a new Uri.
LaunchedEffect(vm.pickedUri) {
val uri = vm.pickedUri ?: return@LaunchedEffect
if (vm.sourceMetadata != null) return@LaunchedEffect
val probed = probeSourceMetadata(context, uri)
if (probed != null) vm.onVideoPicked(uri, probed)
}
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
val pickedUri = vm.pickedUri
if (pickedUri == null) {
EmptyPickVideoCard(
onClick = {
pickLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly),
)
},
)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(R.string.hls_pick_video_helper),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
PickedVideoCard(
vm = vm,
onChange = {
vm.clearPickedVideo()
pickLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly),
)
},
)
Spacer(Modifier.height(16.dp))
FormFields(vm)
Spacer(Modifier.height(24.dp))
Button(
onClick = { vm.publish(context) },
enabled = vm.title.isNotBlank() && vm.selectedServer != null && vm.selectedRenditionLabels.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.hls_publish_button))
}
}
}
}
@Composable
private fun EmptyPickVideoCard(onClick: () -> Unit) {
Card(
modifier =
Modifier
.fillMaxWidth()
.height(200.dp)
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Default.VideoLibrary,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.hls_pick_video_primary),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
@Composable
private fun PickedVideoCard(
vm: NewHlsVideoViewModel,
onChange: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.VideoLibrary,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp),
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = vm.pickedUri?.lastPathSegment ?: "Video",
style = MaterialTheme.typography.bodyLarge,
)
val meta = vm.sourceMetadata
if (meta != null) {
val duration = "${meta.durationSeconds / 60}:${(meta.durationSeconds % 60).toString().padStart(2, '0')}"
Text(
text = "$duration · ${meta.width}×${meta.height} · ${formatSize(meta.sizeBytes)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextButton(onClick = onChange) {
Text(stringResource(R.string.hls_change_video))
}
}
}
}
@Composable
private fun FormFields(vm: NewHlsVideoViewModel) {
OutlinedTextField(
value = vm.title,
onValueChange = { vm.title = it },
label = { Text(stringResource(R.string.hls_title_label)) },
placeholder = { Text(stringResource(R.string.hls_title_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = vm.description,
onValueChange = { vm.description = it },
label = { Text(stringResource(R.string.hls_description_label)) },
placeholder = { Text(stringResource(R.string.hls_description_placeholder)) },
modifier = Modifier.fillMaxWidth().height(120.dp),
)
Spacer(Modifier.height(16.dp))
// Content warning toggle
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.content_warning),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyLarge,
)
Switch(
checked = vm.sensitiveContent,
onCheckedChange = { vm.sensitiveContent = it },
)
}
if (vm.sensitiveContent) {
OutlinedTextField(
value = vm.contentWarningReason,
onValueChange = { vm.contentWarningReason = it },
placeholder = { Text(stringResource(R.string.hls_content_warning_reason_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
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
Text(
text = stringResource(R.string.file_server),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
val servers by vm.availableServers.collectAsState()
val serverOptions =
remember(servers) {
servers.map { TitleExplainer(it.name, it.baseUrl) }.toImmutableList()
}
TextSpinner(
label = "",
placeholder = vm.selectedServer?.name ?: servers.firstOrNull()?.name ?: "",
options = serverOptions,
onSelect = { index -> servers.getOrNull(index)?.let { vm.selectedServer = it } },
)
Spacer(Modifier.height(16.dp))
// Codec toggle
CodecToggle(
useH265 = vm.useH265,
onChange = { vm.useH265 = it },
)
Spacer(Modifier.height(16.dp))
// Renditions — user can toggle which rungs to upload
RenditionsCheckboxes(vm)
}
@Composable
private fun CodecToggle(
useH265: Boolean,
onChange: (Boolean) -> Unit,
) {
val hevcSupported = remember { CompressorUtils.isHevcEncodingSupported() }
Text(
text = stringResource(R.string.hls_codec_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = useH265 && hevcSupported,
enabled = hevcSupported,
onClick = { onChange(true) },
label = { Text(stringResource(R.string.hls_codec_h265)) },
colors = FilterChipDefaults.filterChipColors(),
)
FilterChip(
selected = !useH265 || !hevcSupported,
onClick = { onChange(false) },
label = { Text(stringResource(R.string.hls_codec_h264)) },
)
}
if (!hevcSupported) {
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.hls_codec_fallback_notice),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun RenditionsCheckboxes(vm: NewHlsVideoViewModel) {
Text(
text = stringResource(R.string.hls_renditions_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
val metadata = vm.sourceMetadata
val sourceShortSide = metadata?.let { minOf(it.width, it.height) }
if (metadata != null) {
Text(
text = stringResource(R.string.hls_renditions_source_format, metadata.width, metadata.height),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
HlsLadder.default().renditions.forEach { rendition ->
val label = rendition.resolution.label
val aboveSource = sourceShortSide != null && rendition.resolution.shortSide > sourceShortSide
val enabled = !aboveSource
val checked = label in vm.selectedRenditionLabels && !aboveSource
Row(
modifier =
Modifier.clickable(enabled = enabled) {
vm.selectedRenditionLabels =
if (checked) {
vm.selectedRenditionLabels - label
} else {
vm.selectedRenditionLabels + label
}
},
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = checked,
enabled = enabled,
onCheckedChange = {
vm.selectedRenditionLabels =
if (it) vm.selectedRenditionLabels + label else vm.selectedRenditionLabels - label
},
)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color =
if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ProgressBody(
vm: NewHlsVideoViewModel,
state: HlsPublishState,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 24.dp),
) {
Text(
text = stringResource(R.string.hls_publishing_header_format, vm.title),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(24.dp))
PhaseRow(
label = stringResource(R.string.hls_state_transcoding_format, (state as? HlsPublishState.Transcoding)?.currentLabel?.ifBlank { "" } ?: ""),
active = state is HlsPublishState.Transcoding,
done = state is HlsPublishState.Uploading || state is HlsPublishState.Publishing,
progressFraction = (state as? HlsPublishState.Transcoding)?.percent?.let { it / 100f },
)
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
// Persist the last-observed upload count across transitions so the Uploading row
// doesn't flicker back to "0 of 0" whenever state flips to Transcoding (between
// renditions) or Publishing (after the last upload). The counter should read
// monotonically: 1 of N → 2 of N → … → N of N as uploads actually complete.
var lastDone by remember { mutableIntStateOf(0) }
var lastTotal by remember { mutableIntStateOf(0) }
LaunchedEffect(state) {
if (state is HlsPublishState.Uploading) {
lastDone = state.done
lastTotal = state.total
}
}
val uploadingFraction =
if (lastTotal > 0) lastDone.toFloat() / lastTotal else null
val uploadingLabel =
when {
// Currently in flight: present-tense, file label in the line.
state is HlsPublishState.Uploading && state.currentLabel.isNotBlank() -> {
stringResource(
R.string.hls_state_uploading_with_label_format,
state.currentLabel,
state.done,
state.total,
)
}
// Currently in flight, no file label (unreachable in practice — orchestrator
// always sets a label — but kept for completeness).
state is HlsPublishState.Uploading -> {
stringResource(R.string.hls_state_uploading_format, state.done, state.total)
}
// Between uploads or after all uploads finish: past-tense, monotonic count.
// The last uploaded file's index sticks on screen while the transcoder
// works on the next rendition, and lands on "Uploaded N of N" when the row
// flips to its checkmark/done state.
lastTotal > 0 -> {
stringResource(R.string.hls_state_uploaded_format, lastDone, lastTotal)
}
// Nothing has started uploading yet (very beginning of publish flow).
else -> {
stringResource(R.string.hls_state_uploading_idle)
}
}
PhaseRow(
label = uploadingLabel,
active = state is HlsPublishState.Uploading,
done = state is HlsPublishState.Publishing,
progressFraction = uploadingFraction,
)
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
PhaseRow(
label = stringResource(R.string.hls_state_publishing),
active = state is HlsPublishState.Publishing,
done = false,
progressFraction = null,
)
Spacer(Modifier.height(32.dp))
OutlinedButton(
onClick = { vm.cancel() },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.cancel))
}
}
}
@Composable
private fun PhaseRow(
label: String,
active: Boolean,
done: Boolean,
progressFraction: Float?,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
when {
done -> {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = Color(0xFF22C55E),
modifier = Modifier.size(20.dp),
)
}
active -> {
Spacer(
Modifier
.size(20.dp)
.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(10.dp)),
)
}
else -> {
Spacer(Modifier.size(20.dp))
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = if (active) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
if (active && progressFraction != null) {
Spacer(Modifier.height(4.dp))
LinearProgressIndicator(
progress = { progressFraction.coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
@Composable
private fun SuccessBody(
vm: NewHlsVideoViewModel,
state: HlsPublishState.Success,
nav: INav,
) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = Color(0xFF22C55E),
)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(R.string.hls_state_success_title),
style = MaterialTheme.typography.headlineSmall,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.hls_state_success_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp))
Text(
text = state.masterUrl,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
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))
}
}
}
}
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,
state: HlsPublishState.Failure,
) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(R.string.hls_state_failure_title),
style = MaterialTheme.typography.headlineSmall,
)
Spacer(Modifier.height(8.dp))
Text(
text = state.message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp))
Button(
onClick = { vm.reset() },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.hls_try_again))
}
}
}
private suspend fun probeSourceMetadata(
context: android.content.Context,
uri: Uri,
): HlsSourceMetadata? =
withContext(Dispatchers.IO) {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(context, uri)
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: return@withContext null
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: return@withContext null
val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
val (w, h) = if (rotation == 90 || rotation == 270) height to width else width to height
val size =
context.contentResolver
.openFileDescriptor(uri, "r")
?.use { it.statSize } ?: 0L
HlsSourceMetadata(
width = w,
height = h,
durationSeconds = (durationMs / 1000).toInt(),
sizeBytes = size,
)
} catch (_: Exception) {
null
} finally {
runCatching { retriever.release() }
}
}
private fun formatSize(bytes: Long): String {
if (bytes <= 0) return ""
val mb = bytes.toDouble() / (1024 * 1024)
return if (mb >= 1) String.format("%.1f MB", mb) else String.format("%.0f KB", bytes / 1024.0)
}
@@ -0,0 +1,201 @@
/*
* 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.ui.screen.loggedIn.video.hls
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.davotoula.lightcompressor.VideoCodec
import com.davotoula.lightcompressor.hls.HlsLadder
import com.davotoula.lightcompressor.utils.CompressorUtils
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Compose-facing ViewModel for the "Share HD Video" screen. Holds the form state and a single
* [HlsPublishOrchestrator] that runs the transcode upload publish pipeline. The orchestrator
* receives closures that capture the account/context so the VM only needs a [load] call from the
* screen to wire everything together.
*
* This class is intentionally thin all orchestration logic and state-machine tests live in
* [HlsPublishOrchestrator].
*/
@Stable
open class NewHlsVideoViewModel : ViewModel() {
var account: Account? = null
private set
var pickedUri by mutableStateOf<Uri?>(null)
private set
var sourceMetadata by mutableStateOf<HlsSourceMetadata?>(null)
private set
var title by mutableStateOf("")
var description by mutableStateOf("")
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(
HlsLadder
.default()
.renditions
.map { it.resolution.label }
.toSet(),
)
private val _state = MutableStateFlow<HlsPublishState>(HlsPublishState.Idle)
val state: StateFlow<HlsPublishState> = _state.asStateFlow()
private val _availableServers = MutableStateFlow<List<ServerName>>(DEFAULT_MEDIA_SERVERS)
val availableServers: StateFlow<List<ServerName>> = _availableServers.asStateFlow()
private var orchestrator: HlsPublishOrchestrator? = null
private var currentJob: Job? = null
private var serversJob: Job? = null
fun load(
account: Account,
orchestrator: HlsPublishOrchestrator,
) {
this.account = account
this.orchestrator = orchestrator
val initialServers = account.blossomServers.hostNameFlow.value
_availableServers.value = initialServers
if (selectedServer == null || initialServers.none { it == selectedServer }) {
selectedServer = account.settings.defaultFileServer
.takeIf { s -> initialServers.any { it == s } }
?: initialServers.firstOrNull()
?: DEFAULT_MEDIA_SERVERS.first()
}
serversJob?.cancel()
serversJob =
viewModelScope.launch {
account.blossomServers.hostNameFlow.collect { servers ->
_availableServers.value = servers
if (selectedServer == null || servers.none { it == selectedServer }) {
selectedServer = servers.firstOrNull() ?: DEFAULT_MEDIA_SERVERS.first()
}
}
}
}
fun load(
account: Account,
context: Context,
) = load(
account,
createProductionHlsPublishOrchestrator(
state = _state,
account = account,
context = context,
uriProvider = { pickedUri },
),
)
fun onVideoPicked(
uri: Uri,
metadata: HlsSourceMetadata?,
) {
pickedUri = uri
sourceMetadata = metadata
}
fun clearPickedVideo() {
pickedUri = null
sourceMetadata = null
}
fun publish(context: Context) {
val orch = orchestrator ?: return
val server = selectedServer ?: return
if (pickedUri == null) return
if (title.isBlank()) return
if (selectedRenditionLabels.isEmpty()) return
val codec = effectiveCodec(useH265)
val ladder =
HlsLadder(
HlsLadder.default().renditions.filter { it.resolution.label in selectedRenditionLabels },
)
val request =
HlsPublishRequest(
title = title,
description = description,
sensitiveContent = sensitiveContent,
contentWarningReason = contentWarningReason,
codec = codec,
server = server,
ladder = ladder,
durationSeconds = sourceMetadata?.durationSeconds,
)
currentJob =
viewModelScope.launch(Dispatchers.IO) {
orch.publish(request)
}
}
fun cancel() {
currentJob?.cancel()
currentJob = null
orchestrator?.reset()
}
fun reset() {
orchestrator?.reset()
}
override fun onCleared() {
super.onCleared()
currentJob?.cancel()
}
private fun effectiveCodec(wantH265: Boolean): VideoCodec =
if (wantH265 && CompressorUtils.isHevcEncodingSupported()) {
VideoCodec.H265
} else {
VideoCodec.H264
}
}
data class HlsSourceMetadata(
val width: Int,
val height: Int,
val durationSeconds: Int,
val sizeBytes: Long,
)
+40
View File
@@ -2081,6 +2081,46 @@
<string name="media_actions_dialog_title">Media Actions</string>
<string name="playback_actions_dialog_title">Playback</string>
<string name="video_quality_auto">Auto</string>
<!-- HLS multi-resolution video sharing -->
<string name="share_hls_video">HLS Upload</string>
<string name="share_hls_video_drawer_description">Publish multi-resolution HLS to your media server</string>
<string name="hls_pick_video_primary">Pick a video</string>
<string name="hls_pick_video_helper">Your video will be transcoded into multiple resolutions so viewers get smooth playback on any connection.</string>
<string name="hls_change_video">Change</string>
<string name="hls_title_label">Title</string>
<string name="hls_title_placeholder">Give your video a title</string>
<string name="hls_description_label">Description</string>
<string name="hls_description_placeholder">What is this video about?</string>
<string name="hls_content_warning_reason_placeholder">Reason (optional)</string>
<string name="hls_codec_label">Codec</string>
<string name="hls_codec_h265">H.265 (better compression)</string>
<string name="hls_codec_h264">H.264</string>
<string name="hls_codec_fallback_notice">H.265 not available on this device — falling back to H.264.</string>
<string name="hls_renditions_label">Renditions</string>
<string name="hls_renditions_source_format">Source resolution: %1$d×%2$d</string>
<string name="hls_renditions_produce_format">Will produce: %1$s</string>
<string name="hls_renditions_skipped_format">(%1$s skipped — above source)</string>
<string name="hls_rendition_bitrate_kbps_format">%1$d kbps</string>
<string name="hls_rendition_above_source">above source — will be skipped</string>
<string name="hls_publish_button">Publish HD video</string>
<string name="hls_publishing_header_format">Publishing “%1$s”…</string>
<string name="hls_state_transcoding_format">Transcoding %1$s</string>
<string name="hls_state_uploading_idle">Upload</string>
<string name="hls_state_uploading_format">Uploading %1$d of %2$d</string>
<string name="hls_state_uploading_with_label_format">Uploading %1$s (%2$d of %3$d)</string>
<string name="hls_state_uploaded_format">Uploaded %1$d of %2$d</string>
<string name="hls_state_publishing">Publishing event…</string>
<string name="hls_state_success_title">Video published</string>
<string name="hls_state_success_body">Your HD video is live on Nostr.</string>
<string name="hls_state_failure_title">Something went wrong</string>
<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>
<string name="bookmark_item_actions_dialog_title">Bookmark Actions</string>
@@ -0,0 +1,388 @@
/*
* 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.Resolution
import com.davotoula.lightcompressor.VideoCodec
import com.davotoula.lightcompressor.hls.HlsLadder
import com.davotoula.lightcompressor.hls.HlsRenditionSummary
import com.davotoula.lightcompressor.hls.HlsUploadResult
import com.davotoula.lightcompressor.hls.HlsUploaded
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.HlsPublishOrchestrator
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishRequest
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
import java.nio.file.Files
class HlsPublishOrchestratorTest {
private lateinit var workDir: File
private val server = ServerName("Test Blossom", "https://test.example/", ServerType.Blossom)
@Before
fun setUp() {
workDir = Files.createTempDirectory("hls-orchestrator-test").toFile()
}
@After
fun tearDown() {
workDir.deleteRecursively()
}
private fun landscapeSummary(
resolution: Resolution = Resolution.SD_360,
width: Int = 640,
height: Int = 360,
): HlsRenditionSummary =
HlsRenditionSummary(
rendition = Rendition(resolution, bitrateKbps = 500),
mediaPlaylist = "",
playlistFilename = "${resolution.label}/media.m3u8",
width = width,
height = height,
codecString = "avc1.64001f",
combinedFilename = "${resolution.label}.mp4",
)
/**
* Simulates an HlsUploadHelper run: it calls [uploadFile] once per segment (combined .mp4)
* and once per media playlist, in the same order the real helper would, collects every
* returned [HlsUploaded] into the `uploads` map the real helper surfaces, and returns a
* fake [HlsUploadResult] with the supplied rendition summaries.
*/
private fun fakeRunUpload(renditions: List<HlsRenditionSummary> = listOf(landscapeSummary())): suspend (
config: com.davotoula.lightcompressor.hls.HlsConfig,
listener: com.davotoula.lightcompressor.hls.HlsListener,
uploadFile: suspend (File, String) -> HlsUploaded<MediaUploadResult>,
) -> HlsUploadResult<MediaUploadResult> =
{ _, listener, uploadFile ->
val uploads = linkedMapOf<String, HlsUploaded<MediaUploadResult>>()
listener.onStart(renditions.size)
for (summary in renditions) {
val combinedFilename =
requireNotNull(summary.combinedFilename) { "test fixtures must use single-file mode" }
listener.onRenditionStart(summary.rendition)
listener.onProgress(summary.rendition, 50f)
val combinedFile = File(workDir, combinedFilename).apply { writeText("combined-${summary.rendition.resolution.label}") }
uploads[combinedFilename] = uploadFile(combinedFile, combinedFilename)
val playlistFile =
File(workDir, "${summary.rendition.resolution.label}-media.m3u8").apply { writeText("playlist-${summary.rendition.resolution.label}") }
uploads[summary.playlistFilename] = uploadFile(playlistFile, summary.playlistFilename)
}
listener.onComplete("#EXTM3U\nfake-master-playlist")
HlsUploadResult(
masterPlaylist = "#EXTM3U\nrewritten-master-playlist",
renditions = renditions,
uploads = uploads,
)
}
private class CannedUploader : HlsBlobUploader {
var count = 0
override suspend fun upload(
file: File,
contentType: String,
): MediaUploadResult {
count++
return MediaUploadResult(url = "https://cdn.test/$count", sha256 = "sha-$count", size = file.length())
}
}
private fun fakeUploadMaster(uploader: HlsBlobUploader): suspend (HlsBlobUploader, String) -> MediaUploadResult =
{ _, masterPlaylist ->
val tmp = File(workDir, "master-${System.nanoTime()}.m3u8").apply { writeText(masterPlaylist) }
try {
uploader.upload(tmp, "application/vnd.apple.mpegurl")
} finally {
tmp.delete()
}
}
private fun newRequest(
title: String = "My HD Clip",
description: String = "A test clip",
sensitive: Boolean = false,
warningReason: String = "",
ladder: HlsLadder = HlsLadder(listOf(Rendition(Resolution.SD_360, 500))),
) = HlsPublishRequest(
title = title,
description = description,
sensitiveContent = sensitive,
contentWarningReason = warningReason,
codec = VideoCodec.H265,
server = server,
ladder = ladder,
)
@Test
fun happyPathEndsInSuccessWithMasterUrlAndEventId() {
val publishedTemplates = mutableListOf<HlsVideoEventTemplate>()
val canned = CannedUploader()
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runUpload = fakeRunUpload(),
buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl ->
publishedTemplates += tpl
"signed-event-id"
},
)
runBlocking { orchestrator.publish(newRequest()) }
val final = orchestrator.state.value
assertTrue("expected Success, was $final", final is HlsPublishState.Success)
final as HlsPublishState.Success
assertEquals("signed-event-id", final.eventId)
assertTrue("masterUrl should contain https://cdn.test/", final.masterUrl.startsWith("https://cdn.test/"))
assertEquals(1, publishedTemplates.size)
assertTrue(publishedTemplates[0] is HlsVideoEventTemplate.Horizontal)
}
@Test
fun statePhasesVisibleToFakesDuringPublish() {
// Capture state.value at the point each phase's fake runs — this verifies that the
// orchestrator has already transitioned into the right state before dispatching the
// corresponding dep call.
lateinit var orchestrator: HlsPublishOrchestrator
val capturedDuringTranscode = mutableListOf<HlsPublishState>()
val capturedDuringUpload = mutableListOf<HlsPublishState>()
val capturedDuringPublish = mutableListOf<HlsPublishState>()
val canned = CannedUploader()
val capturingRunUpload: suspend (
com.davotoula.lightcompressor.hls.HlsConfig,
com.davotoula.lightcompressor.hls.HlsListener,
suspend (File, String) -> HlsUploaded<MediaUploadResult>,
) -> HlsUploadResult<MediaUploadResult> = { _, listener, uploadFile ->
val summary = landscapeSummary()
val uploads = linkedMapOf<String, HlsUploaded<MediaUploadResult>>()
listener.onStart(1)
listener.onRenditionStart(summary.rendition)
capturedDuringTranscode += orchestrator.state.value
listener.onProgress(summary.rendition, 42f)
capturedDuringTranscode += orchestrator.state.value
val combinedFile = File(workDir, "360p.mp4").apply { writeText("bytes") }
uploads["360p.mp4"] = uploadFile(combinedFile, "360p.mp4")
capturedDuringUpload += orchestrator.state.value
val playlistFile = File(workDir, "360p-media.m3u8").apply { writeText("bytes") }
uploads["360p/media.m3u8"] = uploadFile(playlistFile, "360p/media.m3u8")
listener.onComplete("#EXTM3U\nmaster")
HlsUploadResult(
masterPlaylist = "#EXTM3U\nrewritten",
renditions = listOf(summary),
uploads = uploads,
)
}
orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runUpload = capturingRunUpload,
buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = {
capturedDuringPublish += orchestrator.state.value
"event-id"
},
)
runBlocking { orchestrator.publish(newRequest()) }
assertTrue(capturedDuringTranscode.all { it is HlsPublishState.Transcoding })
assertEquals("360p", (capturedDuringTranscode.last() as HlsPublishState.Transcoding).currentLabel)
assertEquals(42, (capturedDuringTranscode.last() as HlsPublishState.Transcoding).percent)
assertTrue(capturedDuringUpload.single() is HlsPublishState.Uploading)
assertTrue(capturedDuringPublish.single() is HlsPublishState.Publishing)
assertTrue(orchestrator.state.value is HlsPublishState.Success)
}
@Test
fun transcodeExceptionTransitionsToFailure() {
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runUpload = { _, _, _ -> throw RuntimeException("decode failed") },
buildUploader = { CannedUploader() },
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
signAndPublish = { "never" },
)
runBlocking { orchestrator.publish(newRequest()) }
val final = orchestrator.state.value
assertTrue("expected Failure, was $final", final is HlsPublishState.Failure)
assertEquals("decode failed", (final as HlsPublishState.Failure).message)
}
@Test
fun uploadExceptionTransitionsToFailure() {
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runUpload = fakeRunUpload(),
buildUploader = {
HlsBlobUploader { _, _ -> throw RuntimeException("server 500") }
},
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
signAndPublish = { "never" },
)
runBlocking { orchestrator.publish(newRequest()) }
val final = orchestrator.state.value
assertTrue(final is HlsPublishState.Failure)
assertEquals("server 500", (final as HlsPublishState.Failure).message)
}
@Test
fun masterUploadExceptionTransitionsToFailure() {
val canned = CannedUploader()
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runUpload = fakeRunUpload(),
buildUploader = { canned },
uploadMaster = { _, _ -> throw RuntimeException("master upload failed") },
signAndPublish = { "never" },
)
runBlocking { orchestrator.publish(newRequest()) }
val final = orchestrator.state.value
assertTrue(final is HlsPublishState.Failure)
assertEquals("master upload failed", (final as HlsPublishState.Failure).message)
}
@Test
fun publishExceptionTransitionsToFailure() {
val canned = CannedUploader()
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runUpload = fakeRunUpload(),
buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = { throw RuntimeException("relay rejected") },
)
runBlocking { orchestrator.publish(newRequest()) }
val final = orchestrator.state.value
assertTrue(final is HlsPublishState.Failure)
assertEquals("relay rejected", (final as HlsPublishState.Failure).message)
}
@Test
fun sensitiveContentPassesContentWarningIntoTemplate() {
val captured = mutableListOf<HlsVideoEventTemplate>()
val canned = CannedUploader()
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runUpload = fakeRunUpload(),
buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl ->
captured += tpl
"event-id"
},
)
runBlocking {
orchestrator.publish(newRequest(sensitive = true, warningReason = "NSFW"))
}
val template = (captured.single() as HlsVideoEventTemplate.Horizontal).template
val cw = template.tags.firstOrNull { it.isNotEmpty() && it[0] == "content-warning" }
assertNotNull(cw)
assertEquals("NSFW", cw!![1])
}
@Test
fun portraitRenditionsProduceVerticalTemplate() {
val portrait =
listOf(
HlsRenditionSummary(
rendition = Rendition(Resolution.SD_360, 500),
mediaPlaylist = "",
playlistFilename = "360p/media.m3u8",
width = 360,
height = 640,
codecString = "avc1.64001f",
combinedFilename = "360p.mp4",
),
)
val captured = mutableListOf<HlsVideoEventTemplate>()
val canned = CannedUploader()
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runUpload = fakeRunUpload(portrait),
buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl ->
captured += tpl
"event-id"
},
)
runBlocking { orchestrator.publish(newRequest()) }
assertTrue(captured.single() is HlsVideoEventTemplate.Vertical)
}
@Test
fun resetRestoresIdleState() {
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runUpload = { _, _, _ -> throw RuntimeException("boom") },
buildUploader = { CannedUploader() },
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
signAndPublish = { "never" },
)
runBlocking { orchestrator.publish(newRequest()) }
assertTrue(orchestrator.state.value is HlsPublishState.Failure)
orchestrator.reset()
assertEquals(HlsPublishState.Idle, orchestrator.state.value)
}
}
@@ -0,0 +1,233 @@
/*
* 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.Resolution
import com.davotoula.lightcompressor.hls.HlsRenditionSummary
import com.davotoula.lightcompressor.hls.HlsUploaded
import com.davotoula.lightcompressor.hls.Rendition
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class HlsVideoEventBuilderTest {
private val landscapeRenditions =
listOf(
summary(Resolution.SD_360, width = 640, height = 360),
summary(Resolution.HD_720, width = 1280, height = 720),
)
private val portraitRenditions =
listOf(
summary(Resolution.SD_360, width = 360, height = 640),
)
private fun summary(
resolution: Resolution,
width: Int,
height: Int,
): HlsRenditionSummary =
HlsRenditionSummary(
rendition = Rendition(resolution, bitrateKbps = 500),
mediaPlaylist = "", // not needed by the builder
playlistFilename = "${resolution.label}/media.m3u8",
width = width,
height = height,
codecString = "avc1.64001f",
combinedFilename = "${resolution.label}.mp4",
)
private fun uploadsFor(renditions: List<HlsRenditionSummary>): Map<String, HlsUploaded<MediaUploadResult>> {
val uploads = linkedMapOf<String, HlsUploaded<MediaUploadResult>>()
renditions.forEach { r ->
val label = r.rendition.resolution.label
uploads["$label.mp4"] =
HlsUploaded(
url = "https://cdn.test/$label.mp4",
metadata =
MediaUploadResult(
url = "https://cdn.test/$label.mp4",
sha256 = "$label-sha",
size = 1_000_000L,
),
)
uploads["$label/media.m3u8"] =
HlsUploaded(
url = "https://cdn.test/$label-media.m3u8",
metadata =
MediaUploadResult(
url = "https://cdn.test/$label-media.m3u8",
sha256 = "$label-playlist-sha",
),
)
}
return uploads
}
private fun input(
renditions: List<HlsRenditionSummary>,
title: String = "My HD Video",
description: String = "A cool video",
alt: String? = null,
duration: Int? = null,
contentWarning: String? = null,
dTag: String? = "fixed-d-tag",
): HlsVideoPublishInput =
HlsVideoPublishInput(
renditions = renditions,
uploads = uploadsFor(renditions),
masterUrl = "https://cdn.test/master.m3u8",
masterSha256 = "master-sha",
title = title,
description = description,
alt = alt,
durationSeconds = duration,
contentWarning = contentWarning,
dTag = dTag,
createdAt = 1_700_000_000L,
)
private fun Array<Array<String>>.findTag(name: String): Array<String>? = firstOrNull { it.isNotEmpty() && it[0] == name }
private fun Array<Array<String>>.findAllTags(name: String): List<Array<String>> = filter { it.isNotEmpty() && it[0] == name }
@Test
fun landscapeRenditionsBuildHorizontalTemplateKind34235() {
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
assertTrue("expected Horizontal template", result is HlsVideoEventTemplate.Horizontal)
val template = (result as HlsVideoEventTemplate.Horizontal).template
assertEquals(VideoHorizontalEvent.KIND, template.kind)
assertEquals("A cool video", template.content)
}
@Test
fun portraitRenditionsBuildVerticalTemplateKind34236() {
val result = HlsVideoEventBuilder.build(input(portraitRenditions))
assertTrue("expected Vertical template", result is HlsVideoEventTemplate.Vertical)
val template = (result 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 title = tags.findTag("title")
assertNotNull(title)
assertEquals("My HD Video", title!![1])
val d = tags.findTag("d")
assertNotNull(d)
assertEquals("fixed-d-tag", d!![1])
}
@Test
fun templateContainsOneImetaForMasterAndOnePerRendition() {
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
val imetas = tags.findAllTags("imeta")
// 1 master + 2 renditions
assertEquals(3, imetas.size)
// First imeta is the master
val masterImeta = imetas[0].joinToString("|")
assertTrue(masterImeta.contains("url https://cdn.test/master.m3u8"))
assertTrue(masterImeta.contains("m application/vnd.apple.mpegurl"))
// Subsequent imetas are per-rendition playlist URLs
val r360Imeta = imetas[1].joinToString("|")
assertTrue("360p imeta: $r360Imeta", r360Imeta.contains("url https://cdn.test/360p-media.m3u8"))
assertTrue(r360Imeta.contains("m application/vnd.apple.mpegurl"))
assertTrue("360p dim: $r360Imeta", r360Imeta.contains("dim 640x360"))
assertTrue(r360Imeta.contains("x 360p-sha"))
val r720Imeta = imetas[2].joinToString("|")
assertTrue("720p imeta: $r720Imeta", r720Imeta.contains("url https://cdn.test/720p-media.m3u8"))
assertTrue(r720Imeta.contains("dim 1280x720"))
}
@Test
fun durationTagWhenDurationProvided() {
val result =
HlsVideoEventBuilder.build(
input(landscapeRenditions, duration = 123),
)
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
val duration = tags.findTag("duration")
assertNotNull(duration)
assertEquals("123", duration!![1])
}
@Test
fun noDurationTagWhenNotProvided() {
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
assertNull(tags.findTag("duration"))
}
@Test
fun contentWarningTagWhenProvided() {
val result =
HlsVideoEventBuilder.build(
input(landscapeRenditions, contentWarning = "NSFW"),
)
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags
val warning = tags.findTag("content-warning")
assertNotNull(warning)
assertEquals("NSFW", warning!![1])
}
@Test
fun noContentWarningTagWhenNull() {
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
val tags = (result 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 alt = tags.findTag("alt")
assertNotNull(alt)
assertEquals(VideoHorizontalEvent.ALT_DESCRIPTION, alt!![1])
}
@Test
fun verticalTemplateCarriesVerticalAltTag() {
val result = HlsVideoEventBuilder.build(input(portraitRenditions))
val tags = (result as HlsVideoEventTemplate.Vertical).template.tags
val alt = tags.findTag("alt")
assertNotNull(alt)
assertEquals(VideoVerticalEvent.ALT_DESCRIPTION, alt!![1])
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ genaiPrompt = "1.0.0-beta2"
genaiRewriting = "1.0.0-beta1"
languageId = "17.0.6"
lifecycleRuntimeKtx = "2.10.0"
lightcompressor-enhanced = "2.0.0"
lightcompressor-enhanced = "2.2.0"
markdown = "f92ef49c9d"
material3 = "1.9.0"
materialIconsExtended = "1.7.3"