mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
+9
-2
@@ -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
|
||||
}
|
||||
|
||||
+13
-9
@@ -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 =
|
||||
|
||||
+41
@@ -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
|
||||
}
|
||||
+117
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+137
@@ -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) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -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) }
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
+195
@@ -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
|
||||
}
|
||||
+82
@@ -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
|
||||
},
|
||||
)
|
||||
+47
@@ -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()
|
||||
}
|
||||
+789
@@ -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)
|
||||
}
|
||||
+201
@@ -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,
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
+388
@@ -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)
|
||||
}
|
||||
}
|
||||
+233
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user