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:
parent
4b73616083
commit
2defbd2925
3 changed files with 174 additions and 118 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue