Merge pull request #2416 from davotoula/hls-improvements

keep screen on + smooth per-file upload progress bar
This commit is contained in:
Vitor Pamplona
2026-04-15 18:07:12 -04:00
committed by GitHub
8 changed files with 154 additions and 15 deletions
@@ -45,7 +45,9 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.coroutines.executeAsync
import okio.Buffer
import okio.BufferedSink
import okio.ForwardingSource
import okio.source
import java.io.File
import java.io.InputStream
@@ -76,6 +78,7 @@ class BlossomUploader {
okHttpClient: (String) -> OkHttpClient,
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
context: Context,
onProgress: ((bytesWritten: Long, totalBytes: Long) -> Unit)? = null,
): MediaUploadResult {
checkNotInMainThread()
@@ -111,6 +114,7 @@ class BlossomUploader {
okHttpClient,
httpAuth,
context,
onProgress,
)
}.mergeLocalMetadata(localMetadata)
}
@@ -132,6 +136,7 @@ class BlossomUploader {
okHttpClient: (String) -> OkHttpClient,
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
context: Context,
onProgress: ((bytesWritten: Long, totalBytes: Long) -> Unit)? = null,
): MediaUploadResult {
checkNotInMainThread()
@@ -151,7 +156,30 @@ class BlossomUploader {
override fun contentLength() = length
override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
if (onProgress == null) {
inputStream.source().use(sink::writeAll)
} else {
// Close the outer counting wrapper — ForwardingSource.close delegates
// to the underlying source, so one `use` on the outermost wrapper
// covers the whole chain. Canonical okio pattern.
val countingSource =
object : ForwardingSource(inputStream.source()) {
var totalRead = 0L
override fun read(
sink: Buffer,
byteCount: Long,
): Long {
val n = super.read(sink, byteCount)
if (n > 0) {
totalRead += n
onProgress(totalRead, length)
}
return n
}
}
countingSource.use { sink.writeAll(it) }
}
}
}
@@ -28,14 +28,20 @@ import java.io.File
* 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.
* wraps this in an [com.davotoula.lightcompressor.hls.HlsUploaded]-returning lambda for
* [com.davotoula.lightcompressor.hls.HlsUploadHelper.run]; the library itself drives the
* per-rendition map of [MediaUploadResult]s back into
* [com.davotoula.lightcompressor.hls.HlsUploadResult.uploads] so per-rendition sha256/size
* can flow into the NIP-71 event's imeta tags.
*
* The optional [onProgress] callback is invoked as bytes flow to the wire so the UI can show
* a smooth per-file progress bar instead of the coarse "file N of M" counter. Callers that
* don't need byte progress pass nothing — the default is a no-op.
*/
fun interface HlsBlobUploader {
suspend fun upload(
file: File,
contentType: String,
onProgress: (bytesWritten: Long, totalBytes: Long) -> Unit,
): MediaUploadResult
}
@@ -81,7 +81,7 @@ object HlsBlobUploaderFactory {
account: Account,
context: Context,
): HlsBlobUploader =
HlsBlobUploader { file, contentType ->
HlsBlobUploader { file, contentType, onProgress ->
BlossomUploader().upload(
uri = file.toUri(),
contentType = contentType,
@@ -92,6 +92,7 @@ object HlsBlobUploaderFactory {
okHttpClient = ::okHttpClientForHlsUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
onProgress = onProgress,
)
}
@@ -100,16 +101,25 @@ object HlsBlobUploaderFactory {
account: Account,
context: Context,
): HlsBlobUploader =
HlsBlobUploader { file, contentType ->
HlsBlobUploader { file, contentType, onProgress ->
val totalBytes = file.length()
Nip96Uploader().upload(
uri = file.toUri(),
contentType = contentType,
size = file.length(),
size = totalBytes,
alt = null,
sensitiveContent = null,
serverBaseUrl = serverBaseUrl,
okHttpClient = ::okHttpClientForHlsUploads,
onProgress = { /* pipeline reports progress per-upload; NIP-96 per-request progress is not forwarded */ },
onProgress = { percentage ->
// Convert NIP-96's 0..1 fraction to the bytes/total shape the HLS
// orchestrator expects; clamped so the written count never exceeds the file.
val written =
(percentage * totalBytes)
.toLong()
.coerceIn(0L, totalBytes)
onProgress(written, totalBytes)
},
httpAuth = account::createHTTPAuthorization,
context = context,
)
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import java.io.File
data class HlsPublishRequest(
@@ -120,16 +121,49 @@ class HlsPublishOrchestrator(
}
}
// Throttle byte-progress state writes so the uploader's thousand-calls-per-second
// cadence doesn't flood StateFlow. At most one update per ~100ms or per 2% change,
// whichever happens first. Reset at the start of each file. The throttle state is
// held in a small wrapper so its fields can carry @Volatile — the onBytesProgress
// callback runs on OkHttp's worker thread while reset happens on the coroutine's
// IO thread, and we want explicit memory-model guarantees rather than relying on
// implicit happens-before edges from OkHttp + kotlinx.coroutines resumption.
val throttle = ProgressThrottleState()
val onBytesProgress: (Long, Long) -> Unit = { written, total ->
if (total > 0) {
val fraction = (written.toFloat() / total).coerceIn(0f, 1f)
val now = System.currentTimeMillis()
val deltaMs = now - throttle.lastTick
val deltaFraction = fraction - throttle.lastFraction
if (deltaMs >= PROGRESS_THROTTLE_MS || deltaFraction >= PROGRESS_THROTTLE_FRACTION || fraction >= 1f) {
throttle.lastTick = now
throttle.lastFraction = fraction
// Atomic check-then-set: if the state has already flipped to Transcoding
// or Publishing by the time we land here, keep it — don't clobber a
// more recent transition with a stale Uploading snapshot.
_state.update { current ->
if (current is HlsPublishState.Uploading) {
current.copy(currentFileFraction = fraction)
} else {
current
}
}
}
}
}
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++
throttle.reset()
_state.value =
HlsPublishState.Uploading(
done = uploadsDone,
total = totalUploads,
currentLabel = suggestedFilename,
currentFileFraction = 0f,
)
val contentType =
if (suggestedFilename.endsWith(".m3u8")) {
@@ -137,7 +171,7 @@ class HlsPublishOrchestrator(
} else {
HlsContentTypes.FMP4_SEGMENT
}
val result = uploader.upload(file, contentType)
val result = uploader.upload(file, contentType, onBytesProgress)
HlsUploaded(
url =
result.url
@@ -147,11 +181,16 @@ class HlsPublishOrchestrator(
}
uploadsDone++
throttle.reset()
_state.value =
HlsPublishState.Uploading(
done = uploadsDone,
total = totalUploads,
currentLabel = "master.m3u8",
// Master upload isn't instrumented for byte progress (~1-5 KB, sub-second).
// Show a full bar for its brief visible window so the row reads as "done"
// rather than "empty + vanished" when state flips to Publishing.
currentFileFraction = 1f,
)
val masterUpload = uploadMaster(uploader, uploadResult.masterPlaylist)
val masterUrl =
@@ -192,4 +231,25 @@ class HlsPublishOrchestrator(
}
private fun contentWarningOrNull(request: HlsPublishRequest): String? = if (request.sensitiveContent) request.contentWarningReason else null
// Per-publish throttle bookkeeping for byte-progress emissions. Wrapped in a tiny class
// so its fields can carry @Volatile — the onBytesProgress callback runs on OkHttp's
// worker thread while `throttle.reset()` runs on the coroutine's IO thread, and we want
// an explicit memory-model guarantee rather than relying on implicit happens-before
// edges from OkHttp + kotlinx.coroutines resumption.
private class ProgressThrottleState {
@Volatile var lastTick: Long = 0L
@Volatile var lastFraction: Float = -1f
fun reset() {
lastTick = 0L
lastFraction = -1f
}
}
private companion object {
private const val PROGRESS_THROTTLE_MS = 100L
private const val PROGRESS_THROTTLE_FRACTION = 0.02f
}
}
@@ -64,7 +64,10 @@ fun createProductionHlsPublishOrchestrator(
val masterFile = File.createTempFile("hls-master-", ".m3u8", context.cacheDir)
try {
masterFile.writeText(masterPlaylist)
uploader.upload(masterFile, HlsContentTypes.HLS_PLAYLIST)
// Master playlist is ~1-5 KB and uploads in milliseconds, so we don't
// bother piping byte progress for it — the file counter bar flipping from
// "N-1 of N" → "N of N" is enough signal.
uploader.upload(masterFile, HlsContentTypes.HLS_PLAYLIST) { _, _ -> }
} finally {
masterFile.delete()
}
@@ -32,6 +32,11 @@ sealed class HlsPublishState {
val done: Int,
val total: Int,
val currentLabel: String = "",
// Fraction (0f..1f) through the currently-in-flight file, driven by the uploader's
// byte-progress callback. 0f when a new file starts, 1f when it finishes. Lets the
// progress bar move smoothly within a single file's slice instead of only ticking
// once per file.
val currentFileFraction: Float = 0f,
) : HlsPublishState()
data object Publishing : HlsPublishState()
@@ -64,9 +64,11 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -74,6 +76,7 @@ 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.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -488,6 +491,18 @@ private fun ProgressBody(
vm: NewHlsVideoViewModel,
state: HlsPublishState,
) {
// Keep the screen awake while transcoding/uploading/publishing. An HLS publish can take
// many minutes; if the default lock timer fires mid-flow, the app backgrounds, and
// MediaCodec + the OkHttp upload get throttled by the OS. Scoped to this composable so
// the flag clears automatically when publish completes or the user navigates away.
// Keyed on the view instance so that a configuration change (rotation, theme switch)
// that remounts the activity with a new view re-applies the flag to the fresh view.
val view = LocalView.current
DisposableEffect(view) {
view.keepScreenOn = true
onDispose { view.keepScreenOn = false }
}
Column(
modifier =
Modifier
@@ -516,15 +531,22 @@ private fun ProgressBody(
// 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) }
var lastFileFraction by remember { mutableFloatStateOf(0f) }
LaunchedEffect(state) {
if (state is HlsPublishState.Uploading) {
lastDone = state.done
lastTotal = state.total
lastFileFraction = state.currentFileFraction
}
}
// Per-file bar: each upload fills the whole 0→100% width, then the next upload
// starts fresh. Overall progress is carried by the "Uploaded X of N" counter text,
// so the bar can focus on showing strong visible motion for the currently in-flight
// file. The row is only active during actual uploads (PhaseRow hides the bar
// otherwise), so there's no transcoding-gap lingering to worry about.
val uploadingFraction =
if (lastTotal > 0) lastDone.toFloat() / lastTotal else null
if (lastTotal > 0) lastFileFraction.coerceIn(0f, 1f) else null
val uploadingLabel =
when {
// Currently in flight: present-tense, file label in the line.
@@ -113,9 +113,14 @@ class HlsPublishOrchestratorTest {
override suspend fun upload(
file: File,
contentType: String,
onProgress: (Long, Long) -> Unit,
): MediaUploadResult {
count++
return MediaUploadResult(url = "https://cdn.test/$count", sha256 = "sha-$count", size = file.length())
// Emit a single end-of-file progress tick so tests can verify the callback is
// threaded through without coupling to throttling behaviour.
val length = file.length()
onProgress(length, length)
return MediaUploadResult(url = "https://cdn.test/$count", sha256 = "sha-$count", size = length)
}
}
@@ -123,7 +128,7 @@ class HlsPublishOrchestratorTest {
{ _, masterPlaylist ->
val tmp = File(workDir, "master-${System.nanoTime()}.m3u8").apply { writeText(masterPlaylist) }
try {
uploader.upload(tmp, "application/vnd.apple.mpegurl")
uploader.upload(tmp, "application/vnd.apple.mpegurl") { _, _ -> }
} finally {
tmp.delete()
}
@@ -258,7 +263,7 @@ class HlsPublishOrchestratorTest {
_state = MutableStateFlow(HlsPublishState.Idle),
runUpload = fakeRunUpload(),
buildUploader = {
HlsBlobUploader { _, _ -> throw RuntimeException("server 500") }
HlsBlobUploader { _, _, _ -> throw RuntimeException("server 500") }
},
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
signAndPublish = { "never" },