diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt index ff9be88bd1..25581490cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt index 3d59c00705..99637bfbcd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt @@ -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() 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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploader.kt new file mode 100644 index 0000000000..a6050229e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploader.kt @@ -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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt new file mode 100644 index 0000000000..9cae736004 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt new file mode 100644 index 0000000000..b67cded173 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt @@ -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, + val uploads: Map>, + 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, + ) : HlsVideoEventTemplate() + + data class Vertical( + val template: EventTemplate, + ) : 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) } + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 5b4317988c..2950a3fc04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 5d316e403c..a4f50cd67f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { PicturesScreen(accountViewModel, nav) } composableFromEnd { ShortsScreen(accountViewModel, nav) } composableFromEnd { LongsScreen(accountViewModel, nav) } + composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } composable { ChessLobbyScreen(accountViewModel, nav) } composableFromEnd { WalletScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index ff9c8cf4d4..934dc23c74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 8c6f7c3596..07db7d4247 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -410,6 +410,8 @@ sealed class Route { val draft: String? = null, ) : Route() + @Serializable data object NewHlsVideo : Route() + @Serializable data class VoiceReply( val replyToNoteId: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt new file mode 100644 index 0000000000..66c0747f80 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt @@ -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, + private val runUpload: suspend ( + config: HlsConfig, + listener: HlsListener, + uploadFile: suspend (File, String) -> HlsUploaded, + ) -> HlsUploadResult, + private val buildUploader: (ServerName) -> HlsBlobUploader, + private val uploadMaster: suspend (HlsBlobUploader, String) -> MediaUploadResult, + private val signAndPublish: suspend (HlsVideoEventTemplate) -> String, +) { + val state: StateFlow = _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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt new file mode 100644 index 0000000000..c6dfa6bb93 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt @@ -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, + account: Account, + context: Context, + uriProvider: () -> Uri?, +): HlsPublishOrchestrator = + HlsPublishOrchestrator( + _state = state, + runUpload = { config, listener, uploadFile -> + val uri = uriProvider() ?: error("No video picked") + HlsUploadHelper.run( + 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 + }, + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt new file mode 100644 index 0000000000..9fafc653ee --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt @@ -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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt new file mode 100644 index 0000000000..316aeaf623 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt new file mode 100644 index 0000000000..eaccaa3a4b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt @@ -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(null) + private set + var sourceMetadata by mutableStateOf(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(null) + + var selectedRenditionLabels by mutableStateOf( + HlsLadder + .default() + .renditions + .map { it.resolution.label } + .toSet(), + ) + + private val _state = MutableStateFlow(HlsPublishState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val _availableServers = MutableStateFlow>(DEFAULT_MEDIA_SERVERS) + val availableServers: StateFlow> = _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, +) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0d4c4c2e40..07ecc77b28 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2081,6 +2081,46 @@ Media Actions Playback Auto + + + HLS Upload + Publish multi-resolution HLS to your media server + Pick a video + Your video will be transcoded into multiple resolutions so viewers get smooth playback on any connection. + Change + Title + Give your video a title + Description + What is this video about? + Reason (optional) + Codec + H.265 (better compression) + H.264 + H.265 not available on this device — falling back to H.264. + Renditions + Source resolution: %1$d×%2$d + Will produce: %1$s + (%1$s skipped — above source) + %1$d kbps + above source — will be skipped + Publish HD video + Publishing “%1$s”… + Transcoding %1$s + Upload + Uploading %1$d of %2$d + Uploading %1$s (%2$d of %3$d) + Uploaded %1$d of %2$d + Publishing event… + Video published + Your HD video is live on Nostr. + Something went wrong + View note + Done + Try again + Draft note after upload + Open the note composer pre-filled with the title, description and video link so you can tweak it before posting. + Draft note + Pack Actions List Actions Bookmark Actions diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt new file mode 100644 index 0000000000..c1e6453584 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt @@ -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 = listOf(landscapeSummary())): suspend ( + config: com.davotoula.lightcompressor.hls.HlsConfig, + listener: com.davotoula.lightcompressor.hls.HlsListener, + uploadFile: suspend (File, String) -> HlsUploaded, + ) -> HlsUploadResult = + { _, listener, uploadFile -> + val uploads = linkedMapOf>() + 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() + 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() + val capturedDuringUpload = mutableListOf() + val capturedDuringPublish = mutableListOf() + + val canned = CannedUploader() + val capturingRunUpload: suspend ( + com.davotoula.lightcompressor.hls.HlsConfig, + com.davotoula.lightcompressor.hls.HlsListener, + suspend (File, String) -> HlsUploaded, + ) -> HlsUploadResult = { _, listener, uploadFile -> + val summary = landscapeSummary() + val uploads = linkedMapOf>() + 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() + 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() + 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) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt new file mode 100644 index 0000000000..2e762a1ae5 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt @@ -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): Map> { + val uploads = linkedMapOf>() + 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, + 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>.findTag(name: String): Array? = firstOrNull { it.isNotEmpty() && it[0] == name } + + private fun Array>.findAllTags(name: String): List> = 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]) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6bb0a4be3f..0d5f69f920 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"