Perf audit batch 1 (app-side): preserve res-cap on autoplay + detail LazyColumn — vc=78
All checks were successful
build-apk / build-and-publish (push) Successful in 7m36s
gitleaks / scan (push) Successful in 45s

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:
Cobb 2026-06-21 04:44:58 -07:00
parent 4b73616083
commit 2defbd2925
3 changed files with 174 additions and 118 deletions

View file

@ -9,6 +9,17 @@ const val STRAW_SDK_TARGET = 35
// 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:
// * The minibar + the whole collapse/expand morph now render the video's
// 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
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 77
const val STRAW_VERSION_NAME = "0.1.0-CK"
const val STRAW_VERSION_CODE = 78
const val STRAW_VERSION_NAME = "0.1.0-CL"
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.width
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.ExpandLess
@ -139,26 +140,30 @@ fun VideoDetailBody(
VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
}
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(topPadding))
// LazyColumn (was a verticalScroll Column): the related/more-from rows
// now recycle and defer their image decode + 2 progress-overlay flow
// collectors until scrolled into view, instead of mounting ~40 rows
// (~40 decodes + ~80 collectors) eagerly on every video open.
LazyColumn(modifier = Modifier.fillMaxWidth()) {
item { Spacer(modifier = Modifier.height(topPadding)) }
when {
state.loading -> Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 64.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
state.loading -> item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 64.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
}
state.error != null -> Text(
"error: ${state.error}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
state.error != null -> item {
Text(
"error: ${state.error}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
}
else -> {
val d = state.detail
@ -166,11 +171,16 @@ fun VideoDetailBody(
// detail for one frame before vm.load(B) resets. Gate on
// loadedUrl so we never render A's metadata under B.
if (d == null || state.loadedUrl != streamUrl) {
Box(
modifier = Modifier.fillMaxWidth().padding(top = 64.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
item {
Box(
modifier = Modifier.fillMaxWidth().padding(top = 64.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
}
} 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)) {
Text(
text = d.title,
@ -410,15 +420,30 @@ fun VideoDetailBody(
)
}
if (d.related.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
Text(
"Recommended",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
d.related.take(20).forEach { rel ->
}
}
if (d.related.isNotEmpty()) {
item {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Spacer(modifier = Modifier.height(24.dp))
Text(
"Recommended",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
}
}
// 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(
item = rel,
onClick = { onOpenVideo(rel.url, rel.title) },
@ -434,102 +459,117 @@ fun VideoDetailBody(
HorizontalDivider()
}
}
}
if (d.moreFromChannel.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
Text(
if (d.uploader.isBlank()) "More from this channel"
else "More from ${d.uploader}",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
d.moreFromChannel.take(20).forEach { item ->
if (d.moreFromChannel.isNotEmpty()) {
item {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Spacer(modifier = Modifier.height(24.dp))
Text(
if (d.uploader.isBlank()) "More from this channel"
else "More from ${d.uploader}",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
}
}
items(
items = d.moreFromChannel.take(20),
key = { "mfc:" + it.url },
contentType = { "related" },
) { mfc ->
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
RelatedRow(
item = item,
onClick = { onOpenVideo(item.url, item.title) },
item = mfc,
onClick = { onOpenVideo(mfc.url, mfc.title) },
onLongClick = {
actionTarget = VideoActionTarget(
streamUrl = item.url,
title = item.title,
uploader = item.uploader,
thumbnail = item.thumbnail,
streamUrl = mfc.url,
title = mfc.title,
uploader = mfc.uploader,
thumbnail = mfc.thumbnail,
)
},
)
HorizontalDivider()
}
}
if (showSaveToPlaylistDialog) {
SaveToPlaylistDialog(
item = PlaylistItem(
streamUrl = streamUrl,
title = d.title,
thumbnail = d.thumbnail,
uploader = d.uploader,
),
onDismiss = { showSaveToPlaylistDialog = false },
)
}
if (showDownloadDialog) {
val info = state.streamInfo
AlertDialog(
onDismissRequest = { showDownloadDialog = false },
title = { Text("Download") },
text = {
Column {
Text("Pick a format:", style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Saves to Android/data/.../files/Movies/. Visible in any file manager.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val audio = info?.audioOnly?.maxByOrNull { it.bitrate }?.url
if (audio != null) {
val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
val msg = if (id > 0) "audio queued" else "download refused (bad URL)"
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
}
showDownloadDialog = false
}) { Text("Audio") }
Button(onClick = {
val video = info?.combined?.maxByOrNull { it.bitrate }?.url
?: info?.videoOnly?.maxByOrNull { it.bitrate }?.url
if (video != null) {
val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video)
val msg = if (id > 0) "video queued" else "download refused (bad URL)"
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "no video stream", Toast.LENGTH_SHORT).show()
}
showDownloadDialog = false
}) { Text("Video") }
}
},
dismissButton = {
TextButton(onClick = { showDownloadDialog = false }) {
Text("Cancel")
}
},
)
}
}
}
}
}
// Clear the system nav bar so the last related row isn't tucked
// under the gesture pill.
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
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) {
SaveToPlaylistDialog(
item = PlaylistItem(
streamUrl = streamUrl,
title = d.title,
thumbnail = d.thumbnail,
uploader = d.uploader,
),
onDismiss = { showSaveToPlaylistDialog = false },
)
}
if (showDownloadDialog) {
val info = state.streamInfo
AlertDialog(
onDismissRequest = { showDownloadDialog = false },
title = { Text("Download") },
text = {
Column {
Text("Pick a format:", style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Saves to Android/data/.../files/Movies/. Visible in any file manager.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val audio = info?.audioOnly?.maxByOrNull { it.bitrate }?.url
if (audio != null) {
val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
val msg = if (id > 0) "audio queued" else "download refused (bad URL)"
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
}
showDownloadDialog = false
}) { Text("Audio") }
Button(onClick = {
val video = info?.combined?.maxByOrNull { it.bitrate }?.url
?: info?.videoOnly?.maxByOrNull { it.bitrate }?.url
if (video != null) {
val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video)
val msg = if (id > 0) "video queued" else "download refused (bad URL)"
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "no video stream", Toast.LENGTH_SHORT).show()
}
showDownloadDialog = false
}) { Text("Video") }
}
},
dismissButton = {
TextButton(onClick = { showDownloadDialog = false }) {
Text("Cancel")
}
},
)
}
}
}

View file

@ -161,13 +161,18 @@ fun ExpandablePlayer(
}
}
// Entering a video means "watch the video": wipe any audio-only track
// override left by a prior Background/Audio action so DASH picks the
// best video track again.
// Entering a video means "watch the video": re-enable the video track
// (clearing any audio-only override left by a prior Background/Audio
// 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) {
controller?.let {
it.trackSelectionParameters =
androidx.media3.common.TrackSelectionParameters.Builder(context).build()
it.trackSelectionParameters = it.trackSelectionParameters.buildUpon()
.setTrackTypeDisabled(androidx.media3.common.C.TRACK_TYPE_VIDEO, false)
.build()
it.applyMaxResolutionCap()
}
}