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
05381e8d8b
commit
4ab46cf556
3 changed files with 174 additions and 118 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
modifier = Modifier
|
Box(
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.padding(top = 64.dp),
|
.fillMaxWidth()
|
||||||
contentAlignment = Alignment.Center,
|
.padding(top = 64.dp),
|
||||||
) { CircularProgressIndicator() }
|
contentAlignment = Alignment.Center,
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
}
|
||||||
|
|
||||||
state.error != null -> Text(
|
state.error != null -> item {
|
||||||
"error: ${state.error}",
|
Text(
|
||||||
color = MaterialTheme.colorScheme.error,
|
"error: ${state.error}",
|
||||||
modifier = Modifier.padding(16.dp),
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
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) {
|
||||||
Box(
|
item {
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 64.dp),
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
modifier = Modifier.fillMaxWidth().padding(top = 64.dp),
|
||||||
) { CircularProgressIndicator() }
|
contentAlignment = Alignment.Center,
|
||||||
|
) { 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,15 +420,30 @@ fun VideoDetailBody(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (d.related.isNotEmpty()) {
|
}
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
}
|
||||||
Text(
|
|
||||||
"Recommended",
|
if (d.related.isNotEmpty()) {
|
||||||
style = MaterialTheme.typography.titleSmall,
|
item {
|
||||||
fontWeight = FontWeight.SemiBold,
|
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
)
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Text(
|
||||||
d.related.take(20).forEach { rel ->
|
"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(
|
RelatedRow(
|
||||||
item = rel,
|
item = rel,
|
||||||
onClick = { onOpenVideo(rel.url, rel.title) },
|
onClick = { onOpenVideo(rel.url, rel.title) },
|
||||||
|
|
@ -434,102 +459,117 @@ fun VideoDetailBody(
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (d.moreFromChannel.isNotEmpty()) {
|
if (d.moreFromChannel.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
item {
|
||||||
Text(
|
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
if (d.uploader.isBlank()) "More from this channel"
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
else "More from ${d.uploader}",
|
Text(
|
||||||
style = MaterialTheme.typography.titleSmall,
|
if (d.uploader.isBlank()) "More from this channel"
|
||||||
fontWeight = FontWeight.SemiBold,
|
else "More from ${d.uploader}",
|
||||||
)
|
style = MaterialTheme.typography.titleSmall,
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
fontWeight = FontWeight.SemiBold,
|
||||||
d.moreFromChannel.take(20).forEach { item ->
|
)
|
||||||
|
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(
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// Clear the system nav bar so the last related row isn't tucked
|
||||||
// under the gesture pill.
|
// 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
|
// 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue