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
f79e0cc999
commit
6767801fd4
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
|
// 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"
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
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
|
||||||
|
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