From f28f5730e87b701eb645bac21bb5c3097c70f82d Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 15 Apr 2026 23:18:08 +0200 Subject: [PATCH] keep screen on smooth per-file upload progress bar --- .../uploads/blossom/BlossomUploader.kt | 30 ++++++++- .../service/uploads/hls/HlsBlobUploader.kt | 14 +++-- .../uploads/hls/HlsBlobUploaderFactory.kt | 18 ++++-- .../video/hls/HlsPublishOrchestrator.kt | 62 ++++++++++++++++++- .../hls/HlsPublishOrchestratorFactory.kt | 5 +- .../loggedIn/video/hls/HlsPublishState.kt | 5 ++ .../loggedIn/video/hls/NewHlsVideoScreen.kt | 24 ++++++- .../uploads/hls/HlsPublishOrchestratorTest.kt | 11 +++- 8 files changed, 154 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index becf5418b7..c5ac44b9f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -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) } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploader.kt index a6050229e0..07a960e441 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploader.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt index 9cae736004..e7039630f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt index 66c0747f80..ad3d482d34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt @@ -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 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt index c6dfa6bb93..5ede7e68fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt index 9fafc653ee..6513aeb45f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt index 316aeaf623..1cfba55ba8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt @@ -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. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt index c1e6453584..8857cd7cc4 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt @@ -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" },