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:
parent
5e89056f62
commit
b58804e101
3 changed files with 203 additions and 137 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
22
strawApp/src/main/res/layout/inline_player_view.xml
Normal file
22
strawApp/src/main/res/layout/inline_player_view.xml
Normal 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" />
|
||||
Loading…
Add table
Add a link
Reference in a new issue