mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
Merge pull request #2416 from davotoula/hls-improvements
keep screen on + smooth per-file upload progress bar
This commit is contained in:
+29
-1
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -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
|
||||
}
|
||||
|
||||
+14
-4
@@ -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,
|
||||
)
|
||||
|
||||
+61
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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()
|
||||
}
|
||||
|
||||
+5
@@ -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()
|
||||
|
||||
+23
-1
@@ -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.
|
||||
|
||||
+8
-3
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user