From 2defbd29256313faa653a554e361cce11130c840 Mon Sep 17 00:00:00 2001 From: Cobb Date: Sun, 21 Jun 2026 04:44:58 -0700 Subject: [PATCH] =?UTF-8?q?Perf=20audit=20batch=201=20(app-side):=20preser?= =?UTF-8?q?ve=20res-cap=20on=20autoplay=20+=20detail=20LazyColumn=20?= =?UTF-8?q?=E2=80=94=20vc=3D78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 15 +- .../straw/feature/detail/VideoDetailBody.kt | 262 ++++++++++-------- .../straw/feature/player/ExpandablePlayer.kt | 15 +- 3 files changed, 174 insertions(+), 118 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index c2580fe12..416d3abc5 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailBody.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailBody.kt index 768751020..6da3cd03a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailBody.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailBody.kt @@ -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") + } + }, + ) + } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ExpandablePlayer.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ExpandablePlayer.kt index f8e0b1535..a7552815e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ExpandablePlayer.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ExpandablePlayer.kt @@ -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() } }