Perf audit batch 1 (app-side): preserve res-cap on autoplay + detail LazyColumn — vc=78

From the multi-agent perf audit (adversarially verified), the two app-side wins:

- 1.1 Preserve the max-resolution cap on autoplay-next. The enter-video
  trackSelectionParameters reset built from a blank default, silently
  dropping the data-saver ceiling every URL change so autoplay streamed
  uncapped. Now: re-enable the video track via buildUpon + reassert
  applyMaxResolutionCap().
- 2.1 VideoDetailBody verticalScroll Column -> LazyColumn. Related +
  more-from-channel rows recycle and defer each AsyncImage decode + the
  two ThumbnailProgress flow collectors until scrolled into view (was ~40
  decodes + ~80 collectors eager on every open). Namespaced item keys
  (rel:/mfc:) so a url in both lists doesn't crash the list; kept take(20);
  dialogs hoisted out of the lazy content.
This commit is contained in:
Sulkta 2026-06-21 04:44:58 -07:00
parent 05381e8d8b
commit 4ab46cf556
3 changed files with 174 additions and 118 deletions

View file

@ -9,6 +9,17 @@ const val STRAW_SDK_TARGET = 35
// Sulkta fork — Straw // Sulkta fork — Straw
// //
// vc=78 / 0.1.0-CL — perf-audit batch 1 (app-side):
// * Autoplay-next no longer drops the user's max-resolution cap. The
// enter-video track-selection reset built params from scratch, wiping
// the data-saver ceiling on every URL change; now it re-enables the
// video track surgically + re-asserts applyMaxResolutionCap().
// * VideoDetail body is now a LazyColumn, not a verticalScroll Column.
// The related + more-from-channel lists recycle and defer each row's
// image decode + its two progress-overlay flow collectors until
// scrolled into view (was ~40 decodes + ~80 collectors mounted eagerly
// on every video open). Namespaced item keys; dialogs hoisted out.
//
// vc=77 / 0.1.0-CK — morph perf: static poster during collapse/morph: // 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 // * The minibar + the whole collapse/expand morph now render the video's
// static poster, not the live TextureView. Scaling a live-playing // static poster, not the live TextureView. Scaling a live-playing
@ -89,6 +100,6 @@ const val STRAW_SDK_TARGET = 35
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no // strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path. // NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 77 const val STRAW_VERSION_CODE = 78
const val STRAW_VERSION_NAME = "0.1.0-CK" const val STRAW_VERSION_NAME = "0.1.0-CL"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -48,10 +48,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
@ -139,26 +140,30 @@ fun VideoDetailBody(
VideoActionsSheet(target = t, onDismiss = { actionTarget = null }) VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
} }
Column( // LazyColumn (was a verticalScroll Column): the related/more-from rows
modifier = Modifier // now recycle and defer their image decode + 2 progress-overlay flow
.fillMaxWidth() // collectors until scrolled into view, instead of mounting ~40 rows
.verticalScroll(rememberScrollState()), // (~40 decodes + ~80 collectors) eagerly on every video open.
) { LazyColumn(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.height(topPadding)) item { Spacer(modifier = Modifier.height(topPadding)) }
when { when {
state.loading -> Box( state.loading -> item {
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 64.dp), .padding(top = 64.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { CircularProgressIndicator() } ) { CircularProgressIndicator() }
}
state.error != null -> Text( state.error != null -> item {
Text(
"error: ${state.error}", "error: ${state.error}",
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
) )
}
else -> { else -> {
val d = state.detail val d = state.detail
@ -166,11 +171,16 @@ fun VideoDetailBody(
// detail for one frame before vm.load(B) resets. Gate on // detail for one frame before vm.load(B) resets. Gate on
// loadedUrl so we never render A's metadata under B. // loadedUrl so we never render A's metadata under B.
if (d == null || state.loadedUrl != streamUrl) { if (d == null || state.loadedUrl != streamUrl) {
item {
Box( Box(
modifier = Modifier.fillMaxWidth().padding(top = 64.dp), modifier = Modifier.fillMaxWidth().padding(top = 64.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { CircularProgressIndicator() } ) { CircularProgressIndicator() }
}
} else { } else {
// Header (title/channel/stats/pills/Details) — one item;
// fixed content, doesn't benefit from row recycling.
item {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Text( Text(
text = d.title, text = d.title,
@ -410,7 +420,12 @@ fun VideoDetailBody(
) )
} }
}
}
if (d.related.isNotEmpty()) { if (d.related.isNotEmpty()) {
item {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
"Recommended", "Recommended",
@ -418,7 +433,17 @@ fun VideoDetailBody(
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
d.related.take(20).forEach { rel -> }
}
// Namespaced key (rel: / mfc:) — the same video URL can
// appear in BOTH lists; a bare url key crashes the
// LazyColumn with "Key already used".
items(
items = d.related.take(20),
key = { "rel:" + it.url },
contentType = { "related" },
) { rel ->
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
RelatedRow( RelatedRow(
item = rel, item = rel,
onClick = { onOpenVideo(rel.url, rel.title) }, onClick = { onOpenVideo(rel.url, rel.title) },
@ -434,8 +459,11 @@ fun VideoDetailBody(
HorizontalDivider() HorizontalDivider()
} }
} }
}
if (d.moreFromChannel.isNotEmpty()) { if (d.moreFromChannel.isNotEmpty()) {
item {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
if (d.uploader.isBlank()) "More from this channel" if (d.uploader.isBlank()) "More from this channel"
@ -444,23 +472,43 @@ fun VideoDetailBody(
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
d.moreFromChannel.take(20).forEach { item -> }
}
items(
items = d.moreFromChannel.take(20),
key = { "mfc:" + it.url },
contentType = { "related" },
) { mfc ->
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
RelatedRow( RelatedRow(
item = item, item = mfc,
onClick = { onOpenVideo(item.url, item.title) }, onClick = { onOpenVideo(mfc.url, mfc.title) },
onLongClick = { onLongClick = {
actionTarget = VideoActionTarget( actionTarget = VideoActionTarget(
streamUrl = item.url, streamUrl = mfc.url,
title = item.title, title = mfc.title,
uploader = item.uploader, uploader = mfc.uploader,
thumbnail = item.thumbnail, thumbnail = mfc.thumbnail,
) )
}, },
) )
HorizontalDivider() HorizontalDivider()
} }
} }
}
}
}
}
// Clear the system nav bar so the last related row isn't tucked
// under the gesture pill.
item {
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
// Dialogs live at the composable level (not inside a recyclable lazy
// item): state-driven, rendered once. They need the loaded detail.
state.detail?.let { d ->
if (showSaveToPlaylistDialog) { if (showSaveToPlaylistDialog) {
SaveToPlaylistDialog( SaveToPlaylistDialog(
item = PlaylistItem( item = PlaylistItem(
@ -472,7 +520,6 @@ fun VideoDetailBody(
onDismiss = { showSaveToPlaylistDialog = false }, onDismiss = { showSaveToPlaylistDialog = false },
) )
} }
if (showDownloadDialog) { if (showDownloadDialog) {
val info = state.streamInfo val info = state.streamInfo
AlertDialog( AlertDialog(
@ -525,13 +572,6 @@ fun VideoDetailBody(
} }
} }
} }
}
}
// Clear the system nav bar so the last related row isn't tucked
// under the gesture pill.
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable

View file

@ -161,13 +161,18 @@ fun ExpandablePlayer(
} }
} }
// Entering a video means "watch the video": wipe any audio-only track // Entering a video means "watch the video": re-enable the video track
// override left by a prior Background/Audio action so DASH picks the // (clearing any audio-only override left by a prior Background/Audio
// best video track again. // action) — but PRESERVE the user's max-resolution cap. Building params
// from scratch (the old code) silently dropped the data-saver ceiling, so
// every autoplay-next streamed uncapped resolution until the next
// setPlayingFrom re-applied it. Surgical buildUpon + re-assert the cap.
LaunchedEffect(controller, cur.streamUrl) { LaunchedEffect(controller, cur.streamUrl) {
controller?.let { controller?.let {
it.trackSelectionParameters = it.trackSelectionParameters = it.trackSelectionParameters.buildUpon()
androidx.media3.common.TrackSelectionParameters.Builder(context).build() .setTrackTypeDisabled(androidx.media3.common.C.TRACK_TYPE_VIDEO, false)
.build()
it.applyMaxResolutionCap()
} }
} }