VideoDetail vc=73: smooth swipe-dismiss + collapsible Details + clean action bar

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).
This commit is contained in:
Sulkta 2026-06-20 07:07:43 -07:00
parent f79e0cc999
commit 6767801fd4
3 changed files with 203 additions and 137 deletions

View file

@ -16,6 +16,15 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// Sulkta fork — Straw // 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: // vc=23 / 0.1.0-AI — minibar + downloads UI + green theme:
// * MediaController/MediaSessionService unification — single ExoPlayer // * MediaController/MediaSessionService unification — single ExoPlayer
// owned by PlaybackService, every UI surface is a controller client. // 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 // 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 = 72 const val STRAW_VERSION_CODE = 73
const val STRAW_VERSION_NAME = "0.1.0-CF" const val STRAW_VERSION_NAME = "0.1.0-CG"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -10,6 +10,7 @@ import android.app.PictureInPictureParams
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Rational import android.util.Rational
import android.view.LayoutInflater
import android.widget.Toast import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.animation.core.Animatable 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.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween 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.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets 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.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width 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.Headphones
import androidx.compose.material.icons.filled.PictureInPictureAlt import androidx.compose.material.icons.filled.PictureInPictureAlt
import androidx.compose.material.icons.filled.PlayArrow 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.AlertDialog
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -77,6 +89,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -94,6 +107,7 @@ import androidx.media3.ui.PlayerView
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.sulkta.straw.OverlayChromeColor import com.sulkta.straw.OverlayChromeColor
import com.sulkta.straw.OverlayDimColor import com.sulkta.straw.OverlayDimColor
import com.sulkta.straw.R
import com.sulkta.straw.data.PlaylistItem import com.sulkta.straw.data.PlaylistItem
import com.sulkta.straw.feature.playlist.SaveToPlaylistDialog import com.sulkta.straw.feature.playlist.SaveToPlaylistDialog
import com.sulkta.straw.feature.download.DownloadKind 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.formatViews
import com.sulkta.straw.util.stripHtml import com.sulkta.straw.util.stripHtml
@OptIn(ExperimentalLayoutApi::class, UnstableApi::class) @OptIn(UnstableApi::class)
@Composable @Composable
fun VideoDetailScreen( fun VideoDetailScreen(
streamUrl: String, streamUrl: String,
@ -420,138 +434,138 @@ fun VideoDetailScreen(
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
FlowRow( // Action bar — uniform tonal pills in a single horizontally
horizontalArrangement = Arrangement.spacedBy(12.dp), // scrollable row so they never wrap into a ragged block. The
verticalArrangement = Arrangement.spacedBy(8.dp), // 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") } ActionPill(Icons.Filled.Headphones, "Audio") {
OutlinedButton( val c = controller
onClick = { if (c == null) {
val c = controller Toast.makeText(context, "no player", Toast.LENGTH_SHORT).show()
if (c == null) { } else {
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
val r = state.resolved 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() Toast.makeText(context, "stream not ready", Toast.LENGTH_SHORT).show()
return@OutlinedButton } else {
} if (NowPlaying.current.value?.streamUrl != streamUrl && r != null) {
// Optimization: skip the MediaItem build if c.setPlayingFrom(
// the controller is already on this URL. streamUrl = streamUrl,
// claim() in setPlayingFrom is the title = d.title,
// authoritative race-free guard — this uploader = d.uploader,
// check is just to avoid the work. thumbnail = d.thumbnail,
if (NowPlaying.current.value?.streamUrl != streamUrl) { resolved = r,
c.setPlayingFrom( uploaderUrl = d.uploaderUrl,
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 -> // Audio-only: drop the video track. The foreground
Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show() // 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)
Icon( .build()
imageVector = Icons.Filled.PictureInPictureAlt, if (!c.isPlaying) c.play()
contentDescription = null, onMinimize()
modifier = Modifier.size(18.dp), }
) }
Spacer(modifier = Modifier.width(6.dp))
Text("Popout")
} }
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 { val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain" type = "text/plain"
putExtra(Intent.EXTRA_TEXT, streamUrl) putExtra(Intent.EXTRA_TEXT, streamUrl)
putExtra(Intent.EXTRA_SUBJECT, d.title) putExtra(Intent.EXTRA_SUBJECT, d.title)
} }
context.startActivity(Intent.createChooser(send, "Share video")) 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)) Spacer(modifier = Modifier.height(20.dp))
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) // Collapsible "Details" — the description, rolled up by
Spacer(modifier = Modifier.height(8.dp)) // default and sitting just above the recommendations. Resets
// Cap input length before regex passes — defends against // to collapsed on each new video (keyed by streamUrl).
// ANR on multi-MB descriptions. var detailsExpanded by remember(streamUrl) { mutableStateOf(false) }
Text( Row(
text = stripHtml(d.description.take(20_000)).take(2000), modifier = Modifier
style = MaterialTheme.typography.bodySmall, .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()) { if (d.related.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp)) 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 * Inline player surface inside VideoDetail's 16:9 thumbnail box. Renders
* a PlayerView bound to the shared LocalStrawController the same * a PlayerView bound to the shared LocalStrawController the same
@ -864,21 +894,26 @@ private fun InlinePlayer(
else -> { else -> {
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
PlayerView(ctx).apply { // Inflate from XML to get a TEXTURE_VIEW surface. A
player = controller // SurfaceView is composited separately from the view
useController = true // hierarchy and does NOT follow a Compose graphicsLayer
// Same surface-handoff polish as the // transform — so the swipe-down-to-minimize drag left
// fullscreen PlayerView — hold the last // the video frame lagging behind the rest of the page
// frame on dispose so the inline ↔ // (the stutter). A TextureView draws into the view tree
// fullscreen transition doesn't flash // and translates in lockstep, so the dismiss is smooth.
// black between detach + reattach. // use_controller + keep_content_on_player_reset are set
setKeepContentOnPlayerReset(true) // in the XML; the latter holds the last frame on dispose
// Don't let the device timeout while the // so the inline ↔ fullscreen transition doesn't flash
// inline player is on-screen with the // black between detach + reattach.
// user reading the description. Detaches (LayoutInflater.from(ctx)
// automatically when this view goes away. .inflate(R.layout.inline_player_view, null) as PlayerView)
keepScreenOn = true .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 }, update = { it.player = controller },
onRelease = { it.player = null }, onRelease = { it.player = null },

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2026 Sulkta
SPDX-License-Identifier: GPL-3.0-or-later
Inline player for VideoDetail. surface_type=texture_view is deliberate:
a SurfaceView is composited by the system separately from the Compose
view tree and does NOT follow a 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
hierarchy and translates in lockstep, so the dismiss animation is
smooth. No DRM here, so TextureView's lack of a secure surface costs
us nothing.
-->
<androidx.media3.ui.PlayerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:surface_type="texture_view"
app:use_controller="true"
app:keep_content_on_player_reset="true"
app:resize_mode="fit" />