Skip to content

Commit da2675b

Browse files
eriedclaude
andcommitted
feat(studio): redesign render overlay as a full-screen File Export view
Replace the dimmed-scrim "Rendering" overlay with a black full-screen export screen matching the requested design: an X (top-left) to cancel, a "File Export" title, a large live percent, "Exporting file…", a framed thumbnail of the clip being rendered, and "Stay on current screen". The thumbnail is a small software copy of a rendered frame, refreshed at most every 5s to stay cheap (captured from frame 0 and then throttled in the frame loop; works for both the offscreen and on-screen paths). It doubles as a progress bar: the finished portion (left -> right) shows bright while the rest is veiled, tied to render progress. The X still routes to the existing cancel-confirm. Verified on device: black screen, live % (2%->17%), frame thumbnail with the left-reveal veil, and X -> "Cancel rendering?". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GADLyChheAoMX9dQbRgRnH
1 parent 3ad6735 commit da2675b

2 files changed

Lines changed: 126 additions & 38 deletions

File tree

app/src/main/java/com/eried/eucplanet/ui/studio/OverlayStudioScreen.kt

Lines changed: 123 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
1212
import androidx.activity.result.PickVisualMediaRequest
1313
import androidx.activity.result.contract.ActivityResultContracts
1414
import androidx.compose.foundation.background
15+
import androidx.compose.foundation.Image
1516
import androidx.compose.foundation.border
1617
import androidx.compose.foundation.gestures.detectTapGestures
1718
import androidx.compose.foundation.layout.Arrangement
@@ -39,6 +40,8 @@ import androidx.compose.material.icons.filled.PhotoCamera
3940
import androidx.compose.material.icons.filled.PhotoLibrary
4041
import androidx.compose.material.icons.filled.Stop
4142
import androidx.compose.material3.Icon
43+
import androidx.compose.material3.IconButton
44+
import androidx.compose.foundation.layout.aspectRatio
4245
import androidx.compose.material3.MaterialTheme
4346
import androidx.compose.material3.SnackbarDuration
4447
import androidx.compose.material3.SnackbarHost
@@ -73,6 +76,9 @@ import androidx.compose.ui.draw.rotate
7376
import androidx.compose.ui.geometry.Offset
7477
import androidx.compose.ui.geometry.Size
7578
import androidx.compose.ui.graphics.Color
79+
import androidx.compose.ui.graphics.asImageBitmap
80+
import androidx.compose.ui.layout.ContentScale
81+
import kotlin.math.roundToInt
7682
import androidx.compose.ui.graphics.asAndroidBitmap
7783
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
7884
import 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
19111996
private fun CameraPermissionCard(modifier: Modifier, onGrant: () -> Unit) {
19121997
Column(

app/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,9 @@
11261126
<!-- Studio: render progress overlay -->
11271127
<string name="studio_rendering">Rendering</string>
11281128
<string name="studio_rendering_cancel">Cancel</string>
1129+
<string name="studio_export_title">File Export</string>
1130+
<string name="studio_export_status">Exporting file…</string>
1131+
<string name="studio_export_stay">Stay on current screen</string>
11291132
<string name="studio_dlg_cancel_render_title">Cancel rendering?</string>
11301133
<string name="studio_dlg_cancel_render_body">The replay clip will not be saved.</string>
11311134
<string name="studio_dlg_cancel_render_confirm">Cancel render</string>

0 commit comments

Comments
 (0)