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:
Cobb 2026-06-20 07:07:43 -07:00
parent 5e89056f62
commit b58804e101
3 changed files with 203 additions and 137 deletions

View file

@ -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 },

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2026 Sulkta-Coop
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" />