feat(hls): checkboxes to pick which renditions to upload

Users hit a server upload failure and asked for the ability to skip the
biggest renditions. The LightCompressor HlsLadder API already supports
filtering — this commit wires that through the full stack:

- HlsTranscoder.transcode() now accepts an HlsLadder parameter
  defaulting to HlsLadder.default(), and passes it into HlsConfig.
- HlsPublishRequest gains a ladder field.
- HlsPublishOrchestrator.runTranscode callback now takes the ladder as
  a fourth parameter; the orchestrator forwards request.ladder into it.
- The production factory captures the ladder on each publish.
- NewHlsVideoViewModel tracks selectedRenditionLabels as a mutable Set
  defaulting to all five default rungs. publish() builds an HlsLadder
  from that set by filtering HlsLadder.default().renditions and
  refuses to publish when the set is empty.
- NewHlsVideoScreen replaces the read-only renditions preview with a
  RenditionsCheckboxes composable. Each default rung renders as a
  Checkbox + label + bitrate; rungs above the detected source short
  side are disabled with an "above source — will be skipped" subline
  so the user cannot accidentally select a rendition the library
  would drop anyway.
- Publish button gates on selectedRenditionLabels.isNotEmpty() so an
  empty selection never fires the pipeline.

Tests updated for the new runTranscode signature.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
davotoula
2026-04-15 15:33:48 +02:00
co-authored by Claude Opus 4.5
parent 453bdab202
commit a4787c5fdc
7 changed files with 94 additions and 48 deletions
@@ -25,6 +25,7 @@ import android.net.Uri
import com.davotoula.lightcompressor.HlsPreparer
import com.davotoula.lightcompressor.VideoCodec
import com.davotoula.lightcompressor.hls.HlsConfig
import com.davotoula.lightcompressor.hls.HlsLadder
import kotlinx.coroutines.CancellationException
import java.io.File
@@ -49,11 +50,12 @@ object HlsTranscoder {
uri: Uri,
workDir: File,
codec: VideoCodec,
ladder: HlsLadder = HlsLadder.default(),
onRenditionProgress: (label: String, percent: Int) -> Unit = { _, _ -> },
): HlsBundle {
workDir.mkdirs()
val session = HlsTranscodingSession(workDir, onRenditionProgress)
val config = HlsConfig(codec = codec)
val config = HlsConfig(codec = codec, ladder = ladder)
HlsPreparer.start(
context = context,
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls
import com.davotoula.lightcompressor.VideoCodec
import com.davotoula.lightcompressor.hls.HlsLadder
import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploader
import com.vitorpamplona.amethyst.service.uploads.hls.HlsBundle
import com.vitorpamplona.amethyst.service.uploads.hls.HlsUploadPipeline
@@ -40,6 +41,7 @@ data class HlsPublishRequest(
val contentWarningReason: String,
val codec: VideoCodec,
val server: ServerName,
val ladder: HlsLadder = HlsLadder.default(),
val durationSeconds: Int? = null,
)
@@ -56,6 +58,7 @@ class HlsPublishOrchestrator(
private val runTranscode: suspend (
workDir: File,
codec: VideoCodec,
ladder: HlsLadder,
onProgress: (label: String, percent: Int) -> Unit,
) -> HlsBundle,
private val buildUploader: (ServerName) -> HlsBlobUploader,
@@ -69,7 +72,7 @@ class HlsPublishOrchestrator(
try {
_state.value = HlsPublishState.Transcoding(currentLabel = "", percent = 0)
val bundle =
runTranscode(workDir, request.codec) { label, percent ->
runTranscode(workDir, request.codec, request.ladder) { label, percent ->
_state.value = HlsPublishState.Transcoding(label, percent)
}
@@ -45,13 +45,14 @@ fun createProductionHlsPublishOrchestrator(
): HlsPublishOrchestrator =
HlsPublishOrchestrator(
_state = state,
runTranscode = { workDir, codec, onProgress ->
runTranscode = { workDir, codec, ladder, onProgress ->
val uri = uriProvider() ?: error("No video picked")
HlsTranscoder.transcode(
context = context,
uri = uri,
workDir = workDir,
codec = codec,
ladder = ladder,
onRenditionProgress = onProgress,
)
},
@@ -48,6 +48,7 @@ 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
@@ -189,7 +190,7 @@ private fun IdleBody(vm: NewHlsVideoViewModel) {
Spacer(Modifier.height(24.dp))
Button(
onClick = { vm.publish(context) },
enabled = vm.title.isNotBlank() && vm.selectedServer != null,
enabled = vm.title.isNotBlank() && vm.selectedServer != null && vm.selectedRenditionLabels.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.hls_publish_button))
@@ -375,8 +376,8 @@ private fun FormFields(vm: NewHlsVideoViewModel) {
Spacer(Modifier.height(16.dp))
// Renditions preview (read-only)
RenditionsPreview(vm.sourceMetadata)
// Renditions — user can toggle which rungs to upload
RenditionsCheckboxes(vm)
}
@Composable
@@ -419,51 +420,73 @@ private fun CodecToggle(
}
@Composable
private fun RenditionsPreview(metadata: HlsSourceMetadata?) {
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))
if (metadata == null) {
Text(
text = "360p · 540p · 720p · 1080p · 4K",
style = MaterialTheme.typography.bodyMedium,
)
return
}
val shortSide = minOf(metadata.width, metadata.height)
val ladder =
HlsLadder
.default()
.forSource(shortSide)
.renditions
.map { it.resolution.label }
val skipped =
HlsLadder
.default()
.renditions
.map { it.resolution.label }
.filter { it !in ladder }
Text(
text = stringResource(R.string.hls_renditions_source_format, metadata.width, metadata.height),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
Text(
text = stringResource(R.string.hls_renditions_produce_format, ladder.joinToString(" · ")),
style = MaterialTheme.typography.bodyMedium,
)
if (skipped.isNotEmpty()) {
Spacer(Modifier.height(2.dp))
val metadata = vm.sourceMetadata
val sourceShortSide = metadata?.let { minOf(it.width, it.height) }
if (metadata != null) {
Text(
text = stringResource(R.string.hls_renditions_skipped_format, skipped.joinToString(", ")),
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))
}
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
.fillMaxWidth()
.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
},
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color =
if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
val subline =
if (aboveSource) {
stringResource(R.string.hls_rendition_above_source)
} else {
stringResource(R.string.hls_rendition_bitrate_kbps_format, rendition.bitrateKbps)
}
Text(
text = subline,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -29,6 +29,7 @@ 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
@@ -66,6 +67,14 @@ open class NewHlsVideoViewModel : ViewModel() {
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()
@@ -136,8 +145,13 @@ open class NewHlsVideoViewModel : ViewModel() {
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,
@@ -146,6 +160,7 @@ open class NewHlsVideoViewModel : ViewModel() {
contentWarningReason = contentWarningReason,
codec = codec,
server = server,
ladder = ladder,
durationSeconds = sourceMetadata?.durationSeconds,
)
+2
View File
@@ -2101,6 +2101,8 @@
<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>
@@ -113,7 +113,7 @@ class HlsPublishOrchestratorTest {
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _ -> fakeBundle() },
runTranscode = { _, _, _, _ -> fakeBundle() },
buildUploader = { CannedUploader() },
signAndPublish = { tpl ->
publishedTemplates += tpl
@@ -147,7 +147,7 @@ class HlsPublishOrchestratorTest {
orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, onProgress ->
runTranscode = { _, _, _, onProgress ->
capturedDuringTranscode += orchestrator.state.value
onProgress("360p", 42)
capturedDuringTranscode += orchestrator.state.value
@@ -181,7 +181,7 @@ class HlsPublishOrchestratorTest {
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _ -> throw RuntimeException("decode failed") },
runTranscode = { _, _, _, _ -> throw RuntimeException("decode failed") },
buildUploader = { CannedUploader() },
signAndPublish = { "never" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } },
@@ -199,7 +199,7 @@ class HlsPublishOrchestratorTest {
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _ -> fakeBundle() },
runTranscode = { _, _, _, _ -> fakeBundle() },
buildUploader = {
HlsBlobUploader { _, _ -> throw RuntimeException("server 500") }
},
@@ -219,7 +219,7 @@ class HlsPublishOrchestratorTest {
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _ -> fakeBundle() },
runTranscode = { _, _, _, _ -> fakeBundle() },
buildUploader = { CannedUploader() },
signAndPublish = { throw RuntimeException("relay rejected") },
workDirFactory = { File(workDir, "work").apply { mkdirs() } },
@@ -238,7 +238,7 @@ class HlsPublishOrchestratorTest {
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _ -> fakeBundle() },
runTranscode = { _, _, _, _ -> fakeBundle() },
buildUploader = { CannedUploader() },
signAndPublish = { tpl ->
captured += tpl
@@ -276,7 +276,7 @@ class HlsPublishOrchestratorTest {
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _ -> HlsBundle(workDir, portraitMaster, listOf(rendition)) },
runTranscode = { _, _, _, _ -> HlsBundle(workDir, portraitMaster, listOf(rendition)) },
buildUploader = { CannedUploader() },
signAndPublish = { tpl ->
captured += tpl
@@ -295,7 +295,7 @@ class HlsPublishOrchestratorTest {
val orchestrator =
HlsPublishOrchestrator(
_state = MutableStateFlow(HlsPublishState.Idle),
runTranscode = { _, _, _ -> throw RuntimeException("boom") },
runTranscode = { _, _, _, _ -> throw RuntimeException("boom") },
buildUploader = { CannedUploader() },
signAndPublish = { "never" },
workDirFactory = { File(workDir, "work").apply { mkdirs() } },