Expandable player: static poster during collapse/morph, live player only when expanded — vc=77
All checks were successful
build-apk / build-and-publish (push) Successful in 7m40s
gitleaks / scan (push) Successful in 38s

The remaining sluggishness was scaling a live-playing TextureView through
the morph's graphicsLayer every frame. Now the minibar + the whole
collapse/expand morph render the video's static poster (AsyncImage); the
live PlayerView only mounts once settled fully expanded. Audio is
unaffected — it's owned by the foreground service, never stops.
This commit is contained in:
Cobb 2026-06-20 17:02:03 -07:00
parent f6006047ff
commit 4b73616083
2 changed files with 45 additions and 28 deletions

View file

@ -9,6 +9,13 @@ const val STRAW_SDK_TARGET = 35
// Sulkta fork — Straw
//
// vc=77 / 0.1.0-CK — morph perf: static poster during collapse/morph:
// * The minibar + the whole collapse/expand morph now render the video's
// static poster, not the live TextureView. Scaling a live-playing
// TextureView through the morph's graphicsLayer every frame was the
// remaining sluggishness; the live PlayerView only mounts once settled
// fully expanded. Audio is unaffected (it's in the foreground service).
//
// vc=76 / 0.1.0-CJ — expandable-player smoothness pass:
// * The detail body no longer renders to an offscreen buffer every
// frame during the morph (CompositingStrategy.ModulateAlpha) — that
@ -82,6 +89,6 @@ const val STRAW_SDK_TARGET = 35
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 76
const val STRAW_VERSION_NAME = "0.1.0-CJ"
const val STRAW_VERSION_CODE = 77
const val STRAW_VERSION_NAME = "0.1.0-CK"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -385,7 +385,7 @@ fun ExpandablePlayer(
InlinePlayerSurface(
streamUrl = cur.streamUrl,
title = cur.title,
controlsEnabled = fullyExpanded,
expanded = fullyExpanded,
onFullscreen = { onFullscreen(cur.streamUrl, cur.title) },
)
}
@ -413,17 +413,18 @@ private fun BarIconButton(
* The single TextureView-backed PlayerView, plus the resolveplay wiring
* that used to live in VideoDetailScreen's InlinePlayer. Renders a
* thumbnail + spinner until the shared controller has actually swapped to
* this video, then the live surface. [controlsEnabled] gates the Media3
* controller overlay (off while collapsed so taps fall through to the
* expand gesture). Honors the Auto-start-playback setting: when off, a
* Play overlay waits for a tap before priming the stream.
* this video. [expanded] = fully expanded + settled: only then do we mount
* the live PlayerView (+ controls). While collapsed or mid-morph we show a
* static poster instead, so the morph never scales a live TextureView frame
* by frame (the sluggishness). Honors Auto-start-playback: when off, a Play
* overlay waits for a tap before priming the stream.
*/
@OptIn(UnstableApi::class)
@Composable
private fun InlinePlayerSurface(
streamUrl: String,
title: String,
controlsEnabled: Boolean,
expanded: Boolean,
onFullscreen: () -> Unit,
) {
val controller = LocalStrawController.current
@ -533,40 +534,49 @@ private fun InlinePlayerSurface(
}
CircularProgressIndicator(color = Color.White)
}
!expanded -> {
// Collapsed or mid-morph: show the static poster, NOT the live
// TextureView. Scaling a live-playing TextureView through the
// morph's graphicsLayer every frame was the remaining cost —
// only mount the real player once we've settled fully expanded.
// Audio keeps playing via the service the whole time.
val poster = nowPlaying?.thumbnail ?: thumbnail
if (!poster.isNullOrBlank()) {
AsyncImage(
model = poster,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
}
}
else -> {
AndroidView(
factory = { ctx ->
// Inflate from XML for a TEXTURE_VIEW surface — a
// SurfaceView won't follow the graphicsLayer scale,
// which is exactly the morph here.
// SurfaceView won't follow the graphicsLayer scale.
(LayoutInflater.from(ctx)
.inflate(R.layout.inline_player_view, null) as PlayerView)
.apply {
player = controller
useController = controlsEnabled
useController = true
keepScreenOn = true
}
},
update = {
it.player = controller
it.useController = controlsEnabled
},
update = { it.player = controller },
onRelease = { it.player = null },
modifier = Modifier.fillMaxSize(),
)
if (controlsEnabled) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.background(OverlayChromeColor)
.clickable(onClick = onFullscreen),
contentAlignment = Alignment.Center,
) {
Text("", color = Color.White)
}
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.background(OverlayChromeColor)
.clickable(onClick = onFullscreen),
contentAlignment = Alignment.Center,
) {
Text("", color = Color.White)
}
}
}