@@ -12,6 +12,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
1212import androidx.activity.result.PickVisualMediaRequest
1313import androidx.activity.result.contract.ActivityResultContracts
1414import androidx.compose.foundation.background
15+ import androidx.compose.foundation.Image
1516import androidx.compose.foundation.border
1617import androidx.compose.foundation.gestures.detectTapGestures
1718import androidx.compose.foundation.layout.Arrangement
@@ -39,6 +40,8 @@ import androidx.compose.material.icons.filled.PhotoCamera
3940import androidx.compose.material.icons.filled.PhotoLibrary
4041import androidx.compose.material.icons.filled.Stop
4142import androidx.compose.material3.Icon
43+ import androidx.compose.material3.IconButton
44+ import androidx.compose.foundation.layout.aspectRatio
4245import androidx.compose.material3.MaterialTheme
4346import androidx.compose.material3.SnackbarDuration
4447import androidx.compose.material3.SnackbarHost
@@ -73,6 +76,9 @@ import androidx.compose.ui.draw.rotate
7376import androidx.compose.ui.geometry.Offset
7477import androidx.compose.ui.geometry.Size
7578import androidx.compose.ui.graphics.Color
79+ import androidx.compose.ui.graphics.asImageBitmap
80+ import androidx.compose.ui.layout.ContentScale
81+ import kotlin.math.roundToInt
7682import androidx.compose.ui.graphics.asAndroidBitmap
7783import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
7884import androidx.compose.ui.graphics.drawscope.scale
@@ -310,6 +316,9 @@ fun OverlayStudioScreen(
310316 // Replay APNG export: offline frame-by-frame render with a progress bar.
311317 var rendering by remember { mutableStateOf(false ) }
312318 var renderProgress by remember { mutableStateOf(0f ) }
319+ // A small, slowly-updated preview of the frame being rendered, shown on the
320+ // File Export screen with a left-to-right reveal tied to progress.
321+ var renderThumbnail by remember { mutableStateOf< android.graphics.Bitmap ? > (null ) }
313322 var renderCancelRequested by remember { mutableStateOf(false ) }
314323 var showCancelConfirm by remember { mutableStateOf(false ) }
315324 // True while an alpha-less export (JPG / MP4) is rendering and the rider has
@@ -615,6 +624,19 @@ fun OverlayStudioScreen(
615624 // pending gallery image.
616625 var resultUri: Uri ? = null
617626 renderProgress = 0f
627+ renderThumbnail = null
628+ // Small software copy of a frame for the File Export preview; refreshed at
629+ // most every 5s to keep it cheap.
630+ var lastThumbMs = 0L
631+ fun makeThumb (src : android.graphics.Bitmap ): android.graphics.Bitmap ? = runCatching {
632+ val sw = if (src.config == android.graphics.Bitmap .Config .HARDWARE )
633+ src.copy(android.graphics.Bitmap .Config .ARGB_8888 , false ) else src
634+ val tw = 240
635+ val th = (tw.toLong() * (sw?.height ? : 1 ) / (sw?.width ? : 1 )).toInt().coerceAtLeast(1 )
636+ val out = android.graphics.Bitmap .createScaledBitmap(sw!! , tw, th, true )
637+ if (sw != = src) sw.recycle()
638+ out
639+ }.getOrNull()
618640 val fps = 10
619641 val frameMs = 1000 / fps
620642 val span = replayEndMs - replayStartMs
@@ -647,6 +669,8 @@ fun OverlayStudioScreen(
647669 rendering = false
648670 return @LaunchedEffect
649671 }
672+ renderThumbnail = makeThumb(first)
673+ lastThumbMs = System .currentTimeMillis()
650674 // Output size: the chosen Scale percentage of the studio's native
651675 // resolution (smaller = quicker render, smaller file).
652676 val pct = exportPrefs.scale.coerceIn(25 , 100 ) / 100f
@@ -713,13 +737,23 @@ fun OverlayStudioScreen(
713737 // Frame source: offscreen if available, else advance the live layer and
714738 // read it back (the original 2-vsync path). Frame 0 is the on-screen
715739 // `first` either way, so a divergence shows up as a jump at frame 1.
716- suspend fun captureReplayFrame (i : Int ): android.graphics.Bitmap ? =
717- offscreen?.let { s -> runCatching { s.frame(i) }.getOrNull() }
740+ suspend fun captureReplayFrame (i : Int ): android.graphics.Bitmap ? {
741+ val bmp = offscreen?.let { s -> runCatching { s.frame(i) }.getOrNull() }
718742 ? : run {
719743 replayPosMs = replayStartMs + i * stepMs
720744 repeat(2 ) { withFrameNanos {} }
721745 runCatching { graphicsLayer.toImageBitmap().asAndroidBitmap() }.getOrNull()
722746 }
747+ // Refresh the export preview at most every 5s.
748+ if (bmp != null ) {
749+ val now = System .currentTimeMillis()
750+ if (now - lastThumbMs >= 5000L ) {
751+ lastThumbMs = now
752+ makeThumb(bmp)?.let { renderThumbnail = it }
753+ }
754+ }
755+ return bmp
756+ }
723757
724758 var cancelled = false
725759 val ok: Boolean = try {
@@ -1351,50 +1385,57 @@ fun OverlayStudioScreen(
13511385 // No crossfade on the render overlay; fading its scrim mid-render
13521386 // would blink the whole screen when the phone is rotated.
13531387 RotatedFullScreen (deviceRotation, withFade = false ) {
1354- Box (
1355- Modifier
1356- .fillMaxSize()
1357- .background(MaterialTheme .appColors.scrim.copy(alpha = 0.9f )),
1358- contentAlignment = Alignment .Center
1359- ) {
1388+ // Full-screen black export view (a deliberate always-dark surface, so
1389+ // fixed black/white here rather than theme tokens).
1390+ val pct = (renderProgress.coerceIn(0f , 1f ) * 100 ).roundToInt()
1391+ Box (Modifier .fillMaxSize().background(Color .Black )) {
1392+ IconButton (
1393+ onClick = { showCancelConfirm = true },
1394+ modifier = Modifier
1395+ .align(Alignment .TopStart )
1396+ .safeDrawingPadding()
1397+ .padding(8 .dp)
1398+ ) {
1399+ Icon (
1400+ Icons .Default .Close ,
1401+ contentDescription = stringResource(R .string.studio_rendering_cancel),
1402+ tint = Color .White
1403+ )
1404+ }
1405+ Text (
1406+ stringResource(R .string.studio_export_title),
1407+ color = Color .White ,
1408+ fontSize = 18 .sp,
1409+ fontWeight = FontWeight .Bold ,
1410+ modifier = Modifier
1411+ .align(Alignment .TopCenter )
1412+ .safeDrawingPadding()
1413+ .padding(top = 16 .dp)
1414+ )
13601415 Column (
13611416 horizontalAlignment = Alignment .CenterHorizontally ,
1362- modifier = Modifier .padding( 36 .dp )
1417+ modifier = Modifier .align( Alignment . Center )
13631418 ) {
13641419 Text (
1365- stringResource( R .string.studio_rendering) ,
1366- color = MaterialTheme .appColors.textPrimary ,
1367- fontSize = 22 .sp,
1420+ " $pct % " ,
1421+ color = Color . White ,
1422+ fontSize = 34 .sp,
13681423 fontWeight = FontWeight .Bold
13691424 )
1370- Spacer (Modifier .height(18 .dp))
1371- val barWidth = 240 .dp
1372- Box (
1373- Modifier
1374- .width(barWidth)
1375- .height(8 .dp)
1376- .clip(RoundedCornerShape (4 .dp))
1377- .background(MaterialTheme .appColors.gaugeTrack)
1378- ) {
1379- Box (
1380- Modifier
1381- .width(barWidth * renderProgress.coerceIn(0f , 1f ))
1382- .height(8 .dp)
1383- .clip(RoundedCornerShape (4 .dp))
1384- .background(MaterialTheme .appColors.primary)
1385- )
1386- }
1387- Spacer (Modifier .height(22 .dp))
1425+ Spacer (Modifier .height(8 .dp))
13881426 Text (
1389- stringResource(R .string.studio_rendering_keep_open),
1390- color = MaterialTheme .appColors.textPrimary.copy(alpha = 0.55f ),
1391- textAlign = TextAlign .Center ,
1392- modifier = Modifier .width(280 .dp)
1427+ stringResource(R .string.studio_export_status),
1428+ color = Color .White ,
1429+ fontSize = 16 .sp
1430+ )
1431+ Spacer (Modifier .height(28 .dp))
1432+ ExportThumbnail (renderThumbnail, renderProgress.coerceIn(0f , 1f ))
1433+ Spacer (Modifier .height(24 .dp))
1434+ Text (
1435+ stringResource(R .string.studio_export_stay),
1436+ color = Color .White .copy(alpha = 0.6f ),
1437+ fontSize = 14 .sp
13931438 )
1394- Spacer (Modifier .height(18 .dp))
1395- TextButton (onClick = { showCancelConfirm = true }) {
1396- Text (stringResource(R .string.studio_rendering_cancel))
1397- }
13981439 }
13991440 }
14001441 }
@@ -1907,6 +1948,50 @@ private fun RecordingPill(
19071948 }
19081949}
19091950
1951+ /* *
1952+ * Export-progress preview: the latest rendered frame with a left-to-right reveal.
1953+ * The finished portion (`progress`) shows bright; the rest is veiled, so the
1954+ * thumbnail itself doubles as a progress bar. A null frame just shows the veil.
1955+ */
1956+ @Composable
1957+ private fun ExportThumbnail (bmp : android.graphics.Bitmap ? , progress : Float ) {
1958+ val aspect = if (bmp != null && bmp.height > 0 ) {
1959+ bmp.width.toFloat() / bmp.height
1960+ } else 9f / 16f
1961+ Box (
1962+ Modifier
1963+ .height(280 .dp)
1964+ .aspectRatio(aspect)
1965+ .clip(RoundedCornerShape (14 .dp))
1966+ .background(Color (0xFF1A1A1A ))
1967+ .border(2 .dp, Color .White , RoundedCornerShape (14 .dp))
1968+ ) {
1969+ if (bmp != null ) {
1970+ Image (
1971+ bitmap = bmp.asImageBitmap(),
1972+ contentDescription = null ,
1973+ contentScale = ContentScale .Crop ,
1974+ modifier = Modifier .fillMaxSize()
1975+ )
1976+ }
1977+ Box (
1978+ Modifier
1979+ .fillMaxSize()
1980+ .drawWithContent {
1981+ drawContent()
1982+ val revealX = size.width * progress
1983+ if (revealX < size.width) {
1984+ drawRect(
1985+ color = Color .Black .copy(alpha = 0.5f ),
1986+ topLeft = Offset (revealX, 0f ),
1987+ size = Size (size.width - revealX, size.height)
1988+ )
1989+ }
1990+ }
1991+ )
1992+ }
1993+ }
1994+
19101995@Composable
19111996private fun CameraPermissionCard (modifier : Modifier , onGrant : () -> Unit ) {
19121997 Column (
0 commit comments