From b58804e101b82e7d990e93b81b5efedd92dded7c Mon Sep 17 00:00:00 2001 From: Cobb Date: Sat, 20 Jun 2026 07:07:43 -0700 Subject: [PATCH] VideoDetail vc=73: smooth swipe-dismiss + collapsible Details + clean action bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline player → TextureView (XML surface_type) so the swipe-down-to-minimize drag follows the Compose graphicsLayer transform instead of the SurfaceView lagging behind (the stutter). Description folded into a collapsible Details section, collapsed by default, above recommendations. Action buttons restyled into one horizontally-scrollable row of uniform tonal icon pills; dropped the redundant Play button (inline player + fullscreen pill cover it). --- buildSrc/src/main/kotlin/ProjectConfig.kt | 13 +- .../straw/feature/detail/VideoDetailScreen.kt | 305 ++++++++++-------- .../main/res/layout/inline_player_view.xml | 22 ++ 3 files changed, 203 insertions(+), 137 deletions(-) create mode 100644 strawApp/src/main/res/layout/inline_player_view.xml diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index d751e56a8..1947f0807 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -16,6 +16,15 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // Sulkta fork — Straw // +// vc=73 / 0.1.0-CG — VideoDetail cleanup: +// * Inline player → TextureView surface so the swipe-down-to-minimize +// drag is smooth (a SurfaceView won't follow the Compose graphicsLayer +// transform — that was the stutter). +// * Description folded into a collapsible "Details" section, collapsed +// by default, sitting just above the recommendations. +// * Action buttons restyled into one tidy horizontally-scrollable row +// of uniform icon pills (dropped the redundant "Play"). +// // vc=23 / 0.1.0-AI — minibar + downloads UI + green theme: // * MediaController/MediaSessionService unification — single ExoPlayer // owned by PlaybackService, every UI surface is a controller client. @@ -55,6 +64,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // 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 = 72 -const val STRAW_VERSION_NAME = "0.1.0-CF" +const val STRAW_VERSION_CODE = 73 +const val STRAW_VERSION_NAME = "0.1.0-CG" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 6c726aff2..c15c21185 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -10,6 +10,7 @@ import android.app.PictureInPictureParams import android.content.Intent import android.os.Build import android.util.Rational +import android.view.LayoutInflater import android.widget.Toast import androidx.annotation.OptIn import androidx.compose.animation.core.Animatable @@ -17,18 +18,22 @@ import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -38,6 +43,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width @@ -50,11 +56,17 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Headphones import androidx.compose.material.icons.filled.PictureInPictureAlt import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.PlaylistAdd +import androidx.compose.material.icons.filled.Share import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -77,6 +89,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -94,6 +107,7 @@ import androidx.media3.ui.PlayerView import coil3.compose.AsyncImage import com.sulkta.straw.OverlayChromeColor import com.sulkta.straw.OverlayDimColor +import com.sulkta.straw.R import com.sulkta.straw.data.PlaylistItem import com.sulkta.straw.feature.playlist.SaveToPlaylistDialog import com.sulkta.straw.feature.download.DownloadKind @@ -111,7 +125,7 @@ import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.stripHtml -@OptIn(ExperimentalLayoutApi::class, UnstableApi::class) +@OptIn(UnstableApi::class) @Composable fun VideoDetailScreen( streamUrl: String, @@ -420,138 +434,138 @@ fun VideoDetailScreen( } Spacer(modifier = Modifier.height(16.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + // Action bar — uniform tonal pills in a single horizontally + // scrollable row so they never wrap into a ragged block. The + // inline player (and its ⛶) already handle play/fullscreen, + // so the old standalone "Play" button is gone. + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Button(onClick = onPlay) { Text("Play") } - OutlinedButton( - onClick = { - val c = controller - if (c == null) { - Toast.makeText(context, "no player", Toast.LENGTH_SHORT).show() - return@OutlinedButton - } - // Make sure the controller is playing this video - // before backing out — otherwise dropping to the - // minibar would dismiss into an empty slot. - // Optimization: skip the MediaItem build if - // the controller is already on this URL. - // claim() in setPlayingFrom is the - // authoritative race-free guard — this - // check is just to avoid the work. - if (NowPlaying.current.value?.streamUrl != streamUrl) { - val r = state.resolved - if (r == null) { - Toast.makeText(context, "stream not ready", Toast.LENGTH_SHORT).show() - return@OutlinedButton - } - c.setPlayingFrom( - streamUrl = streamUrl, - title = d.title, - uploader = d.uploader, - thumbnail = d.thumbnail, - resolved = r, - uploaderUrl = d.uploaderUrl, - ) - } - // Audio-only: drop video track. Foreground - // service keeps the audio going; minibar takes - // over once we pop off the detail screen. - c.trackSelectionParameters = TrackSelectionParameters.Builder(context) - .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true) - .build() - if (!c.isPlaying) c.play() - onMinimize() - }, - ) { - Icon( - imageVector = Icons.Filled.Headphones, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text("Background") - } - OutlinedButton( - onClick = { - if (activity == null) { - Toast.makeText(context, "PiP: no activity", Toast.LENGTH_SHORT).show() - return@OutlinedButton - } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - Toast.makeText(context, "PiP needs Android 8+", Toast.LENGTH_SHORT).show() - return@OutlinedButton - } - // PiP into nothing isn't useful — bail with a - // Toast if there's no controller / no resolved - // playback to push into it. - val c = controller + ActionPill(Icons.Filled.Headphones, "Audio") { + val c = controller + if (c == null) { + Toast.makeText(context, "no player", Toast.LENGTH_SHORT).show() + } else { val r = state.resolved - if (c == null || r == null) { + // claim() in setPlayingFrom is the race-free guard; + // this check just avoids rebuilding the MediaItem + // when we're already on this URL. + if (NowPlaying.current.value?.streamUrl != streamUrl && r == null) { Toast.makeText(context, "stream not ready", Toast.LENGTH_SHORT).show() - return@OutlinedButton - } - // Optimization: skip the MediaItem build if - // the controller is already on this URL. - // claim() in setPlayingFrom is the - // authoritative race-free guard — this - // check is just to avoid the work. - if (NowPlaying.current.value?.streamUrl != streamUrl) { - c.setPlayingFrom( - streamUrl = streamUrl, - title = d.title, - uploader = d.uploader, - thumbnail = d.thumbnail, - resolved = r, - uploaderUrl = d.uploaderUrl, - ) - } - val params = PictureInPictureParams.Builder() - .setAspectRatio(Rational(16, 9)) - .build() - runCatching { activity.enterPictureInPictureMode(params) } - .onSuccess { ok -> - if (!ok) Toast.makeText(context, "PiP refused", Toast.LENGTH_LONG).show() + } else { + if (NowPlaying.current.value?.streamUrl != streamUrl && r != null) { + c.setPlayingFrom( + streamUrl = streamUrl, + title = d.title, + uploader = d.uploader, + thumbnail = d.thumbnail, + resolved = r, + uploaderUrl = d.uploaderUrl, + ) } - .onFailure { t -> - Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show() - } - }, - ) { - Icon( - imageVector = Icons.Filled.PictureInPictureAlt, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text("Popout") + // Audio-only: drop the video track. The foreground + // service keeps audio going; the minibar takes over + // once we pop off the detail screen. + c.trackSelectionParameters = TrackSelectionParameters.Builder(context) + .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true) + .build() + if (!c.isPlaying) c.play() + onMinimize() + } + } } - OutlinedButton(onClick = { + ActionPill(Icons.Filled.PictureInPictureAlt, "PiP") { + when { + activity == null -> + Toast.makeText(context, "PiP: no activity", Toast.LENGTH_SHORT).show() + Build.VERSION.SDK_INT < Build.VERSION_CODES.O -> + Toast.makeText(context, "PiP needs Android 8+", Toast.LENGTH_SHORT).show() + else -> { + // PiP into nothing isn't useful — bail if there's + // no controller / no resolved playback to push in. + val c = controller + val r = state.resolved + if (c == null || r == null) { + Toast.makeText(context, "stream not ready", Toast.LENGTH_SHORT).show() + } else { + if (NowPlaying.current.value?.streamUrl != streamUrl) { + c.setPlayingFrom( + streamUrl = streamUrl, + title = d.title, + uploader = d.uploader, + thumbnail = d.thumbnail, + resolved = r, + uploaderUrl = d.uploaderUrl, + ) + } + val params = PictureInPictureParams.Builder() + .setAspectRatio(Rational(16, 9)) + .build() + runCatching { activity.enterPictureInPictureMode(params) } + .onSuccess { ok -> + if (!ok) Toast.makeText(context, "PiP refused", Toast.LENGTH_LONG).show() + } + .onFailure { t -> + Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show() + } + } + } + } + } + ActionPill(Icons.Filled.Share, "Share") { val send = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_TEXT, streamUrl) putExtra(Intent.EXTRA_SUBJECT, d.title) } context.startActivity(Intent.createChooser(send, "Share video")) - }) { Text("Share") } - OutlinedButton(onClick = { showDownloadDialog = true }) { - Text("Download") - } - OutlinedButton(onClick = { showSaveToPlaylistDialog = true }) { - Text("Save") } + ActionPill(Icons.Filled.Download, "Download") { showDownloadDialog = true } + ActionPill(Icons.Filled.PlaylistAdd, "Save") { showSaveToPlaylistDialog = true } } Spacer(modifier = Modifier.height(20.dp)) - Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - Spacer(modifier = Modifier.height(8.dp)) - // Cap input length before regex passes — defends against - // ANR on multi-MB descriptions. - Text( - text = stripHtml(d.description.take(20_000)).take(2000), - style = MaterialTheme.typography.bodySmall, - ) + // Collapsible "Details" — the description, rolled up by + // default and sitting just above the recommendations. Resets + // to collapsed on each new video (keyed by streamUrl). + var detailsExpanded by remember(streamUrl) { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { detailsExpanded = !detailsExpanded } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Details", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = if (detailsExpanded) Icons.Filled.ExpandLess + else Icons.Filled.ExpandMore, + contentDescription = if (detailsExpanded) "Collapse details" + else "Expand details", + ) + } + AnimatedVisibility( + visible = detailsExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + // Cap input length before regex passes — defends against + // ANR on multi-MB descriptions. + Text( + text = stripHtml(d.description.take(20_000)).take(2000), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp, bottom = 4.dp), + ) + } if (d.related.isNotEmpty()) { Spacer(modifier = Modifier.height(24.dp)) @@ -741,6 +755,22 @@ private fun RelatedRow( } } +/** + * One action-bar pill: a tonal button with a leading icon + short label. + * Uniform sizing keeps the horizontally-scrollable action row tidy. + */ +@Composable +private fun ActionPill(icon: ImageVector, label: String, onClick: () -> Unit) { + FilledTonalButton( + onClick = onClick, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text(label) + } +} + /** * Inline player surface inside VideoDetail's 16:9 thumbnail box. Renders * a PlayerView bound to the shared LocalStrawController — the same @@ -864,21 +894,26 @@ private fun InlinePlayer( else -> { AndroidView( factory = { ctx -> - PlayerView(ctx).apply { - player = controller - useController = true - // Same surface-handoff polish as the - // fullscreen PlayerView — hold the last - // frame on dispose so the inline ↔ - // fullscreen transition doesn't flash - // black between detach + reattach. - setKeepContentOnPlayerReset(true) - // Don't let the device timeout while the - // inline player is on-screen with the - // user reading the description. Detaches - // automatically when this view goes away. - keepScreenOn = true - } + // Inflate from XML to get a TEXTURE_VIEW surface. A + // SurfaceView is composited separately from the view + // hierarchy and does NOT follow a Compose graphicsLayer + // transform — so the swipe-down-to-minimize drag left + // the video frame lagging behind the rest of the page + // (the stutter). A TextureView draws into the view tree + // and translates in lockstep, so the dismiss is smooth. + // use_controller + keep_content_on_player_reset are set + // in the XML; the latter holds the last frame on dispose + // so the inline ↔ fullscreen transition doesn't flash + // black between detach + reattach. + (LayoutInflater.from(ctx) + .inflate(R.layout.inline_player_view, null) as PlayerView) + .apply { + player = controller + // Don't let the device time out while the inline + // player is on-screen. Detaches automatically + // when this view goes away. + keepScreenOn = true + } }, update = { it.player = controller }, onRelease = { it.player = null }, diff --git a/strawApp/src/main/res/layout/inline_player_view.xml b/strawApp/src/main/res/layout/inline_player_view.xml new file mode 100644 index 000000000..d1af5eb24 --- /dev/null +++ b/strawApp/src/main/res/layout/inline_player_view.xml @@ -0,0 +1,22 @@ + + +