vc=27: swipe-down on detail page + Background/Popout buttons

Three pieces of feedback on vc=26, fixed in one pass:

(1) Swipe-to-minimize was on the fullscreen player, where it fought
PlayerView's own touch handling and felt janky. Moved the gesture to
the VideoDetailScreen — the same place YouTube/NewPipe put it. The
drag handle is the inline-player surface itself (the 16:9 box at
top); outside that, the description scrolls normally. The whole
page translates with the finger via graphicsLayer + an alpha/scale
fade so the motion reads as "the video is being tucked away" rather
than the old jump-and-snap. Threshold 140dp → onMinimize.

Fullscreen keeps the down-arrow button for minimize; the
drag-to-dismiss path is gone from PlayerScreen entirely (along with
its Animatable + density imports). Whichever surface is visible has
exactly one minimize affordance.

(2) The minibar was always visible on every non-Player screen, which
meant it stacked under the VideoDetail page even though the inline
player is already there. Updated visibility predicate to also hide
on VideoDetail — the minibar is now strictly the take-over UI for
when you leave the video page. Tapping the minibar pushes back to
VideoDetail (not fullscreen) so the mental model is symmetrical:
swipe-down to leave, tap to come back.

(3) Two new buttons on the VideoDetail action row:
  Background — disables the video track on the controller and
    pops out of detail. The foreground service keeps audio going;
    the minibar appears on whatever screen you land on. Pre-checks
    that the controller is actually playing this video before
    leaving — otherwise the minibar would dismiss into empty.
  Popout — enters PiP via the activity, same code path as the
    fullscreen overlay button. Same controller pre-check.

Nav helper: added Navigator.resetTo(Screen) so that minimize from
a deep-link entry (where VideoDetail is the only stack item) drops
the user on Home rather than no-op'ing.
This commit is contained in:
Kayos 2026-05-25 11:17:20 -07:00
parent 885398e3bd
commit 35f5affec3
5 changed files with 189 additions and 52 deletions

View file

@ -55,6 +55,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 = 26
const val STRAW_VERSION_NAME = "0.1.0-AL"
const val STRAW_VERSION_CODE = 27
const val STRAW_VERSION_NAME = "0.1.0-AM"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -42,6 +42,17 @@ class Navigator(initial: Screen) {
stack.removeAt(stack.lastIndex)
return true
}
/**
* Replace the entire stack with a single screen. Used by the
* swipe-to-minimize gesture when the user lands directly on a video
* page via a deep link there's nothing to pop back to, so we drop
* them on Home instead.
*/
fun resetTo(s: Screen) {
stack.clear()
stack.add(s)
}
}
@Composable

View file

@ -108,13 +108,18 @@ class StrawActivity : ComponentActivity() {
Box(modifier = Modifier.fillMaxSize()) {
ScreenContent(nav, s = nav.current)
// Persistent minibar — visible on every non-Player
// screen whenever something is loaded.
if (nav.current !is Screen.Player) {
// The minibar is the takeover-when-you-leave UI:
// hide it while you're on the actual video page
// (the inline player IS the player) and hide it
// in fullscreen (which IS the player). Everywhere
// else, audio keeps going and the minibar gives
// you a way back.
val cur = nav.current
if (cur !is Screen.Player && cur !is Screen.VideoDetail) {
MinibarOverlay(
onExpand = {
val item = NowPlaying.current.value ?: return@MinibarOverlay
nav.push(Screen.Player(item.streamUrl, item.title))
nav.push(Screen.VideoDetail(item.streamUrl, item.title))
},
modifier = Modifier.align(Alignment.BottomCenter),
)
@ -157,6 +162,7 @@ class StrawActivity : ComponentActivity() {
streamUrl = s.streamUrl,
initialTitle = s.title,
onPlay = { nav.push(Screen.Player(s.streamUrl, s.title)) },
onMinimize = { if (!nav.pop()) nav.resetTo(Screen.Home) },
onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
)

View file

@ -5,11 +5,19 @@
package com.sulkta.straw.feature.detail
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.Intent
import android.os.Build
import android.util.Rational
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -30,6 +38,8 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.material3.AlertDialog
import androidx.compose.material3.AssistChip
@ -50,19 +60,25 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerView
import coil3.compose.AsyncImage
@ -79,28 +95,51 @@ import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.formatCount
import com.sulkta.straw.util.formatViews
import com.sulkta.straw.util.stripHtml
import kotlinx.coroutines.launch
@OptIn(ExperimentalLayoutApi::class)
@OptIn(ExperimentalLayoutApi::class, UnstableApi::class)
@Composable
fun VideoDetailScreen(
streamUrl: String,
initialTitle: String,
onPlay: () -> Unit,
onMinimize: () -> Unit,
onOpenChannel: (channelUrl: String, name: String) -> Unit,
onOpenVideo: (url: String, title: String) -> Unit,
vm: VideoDetailViewModel = viewModel(),
) {
val state by vm.ui.collectAsStateWithLifecycle()
val context = LocalContext.current
val controller = LocalStrawController.current
val activity = context as? Activity
var showDownloadDialog by remember { mutableStateOf(false) }
var showSaveToPlaylistDialog by remember { mutableStateOf(false) }
// Inline-play state resets when navigating to a different video.
var inlinePlaying by remember(streamUrl) { mutableStateOf(false) }
LaunchedEffect(streamUrl) { vm.load(streamUrl) }
// Swipe-down to minimize. The drag handle is the inline player surface
// (the 16:9 box at the top); we translate the WHOLE page with it so the
// motion reads as "the video is being tucked away" rather than "this
// one widget slid." The graphicsLayer + alpha/scale fade keeps it
// smooth — no per-pixel coroutine churn from offset { }.
val density = LocalDensity.current
val dismissThresholdPx = with(density) { 140.dp.toPx() }
val dragY = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
Column(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
val y = dragY.value
translationY = y
val p = (y / dismissThresholdPx).coerceIn(0f, 1f)
alpha = 1f - p * 0.4f
val s = 1f - p * 0.08f
scaleX = s
scaleY = s
}
.statusBarsPadding()
.verticalScroll(rememberScrollState())
.padding(16.dp),
@ -118,6 +157,33 @@ fun VideoDetailScreen(
else -> {
val d = state.detail ?: return@Column
// Drag-to-minimize gesture lives on the player surface
// itself — same pattern YouTube/NewPipe use. Outside the
// 16:9 box the page scrolls normally, so the drag never
// fights with description scrolling.
val playerDragModifier = Modifier.pointerInput(Unit) {
detectVerticalDragGestures(
onDragEnd = {
if (dragY.value > dismissThresholdPx) {
onMinimize()
} else {
scope.launch {
dragY.animateTo(0f, spring())
}
}
},
onDragCancel = {
scope.launch { dragY.animateTo(0f, spring()) }
},
onVerticalDrag = { _, dy ->
scope.launch {
dragY.snapTo(
(dragY.value + dy).coerceAtLeast(0f),
)
}
},
)
}
if (inlinePlaying) {
InlinePlayer(
streamUrl = streamUrl,
@ -129,7 +195,8 @@ fun VideoDetailScreen(
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(8.dp))
.background(Color.Black),
.background(Color.Black)
.then(playerDragModifier),
)
} else {
Box(
@ -137,7 +204,8 @@ fun VideoDetailScreen(
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(8.dp))
.clickable { inlinePlaying = true },
.clickable { inlinePlaying = true }
.then(playerDragModifier),
contentAlignment = Alignment.Center,
) {
AsyncImage(
@ -219,6 +287,94 @@ fun VideoDetailScreen(
verticalArrangement = 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.
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,
)
}
// 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 needs the controller to actually be playing
// this video, same as Background — otherwise we
// pop out into nothing.
val c = controller
if (c != null && NowPlaying.current.value?.streamUrl != streamUrl) {
val r = state.resolved
if (r != null) {
c.setPlayingFrom(
streamUrl = streamUrl,
title = d.title,
uploader = d.uploader,
thumbnail = d.thumbnail,
resolved = r,
)
}
}
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()
}
},
) {
Icon(
imageVector = Icons.Filled.PictureInPictureAlt,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text("Popout")
}
OutlinedButton(onClick = {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"

View file

@ -4,10 +4,12 @@
*
* Fullscreen player surface. The player itself lives in PlaybackService
* (one ExoPlayer for the whole app); this composable is a thin shell that
* renders a PlayerView bound to the shared MediaController, lets the user
* drag down to dismiss into the minibar, and overlays speed / audio-only
* / share / PiP / minimize controls. SponsorBlock auto-skip lives at the
* activity root in [SponsorBlockSkipLoop].
* renders a PlayerView bound to the shared MediaController and overlays
* speed / audio-only / share / PiP / minimize controls. To minimize, tap
* the down-arrow button (top right) the swipe-down gesture lives on
* the VideoDetail page instead, where it doesn't fight PlayerView's own
* touch handling. SponsorBlock auto-skip lives at the activity root in
* [SponsorBlockSkipLoop].
*/
package com.sulkta.straw.feature.player
@ -19,17 +21,13 @@ import android.os.Build
import android.util.Rational
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
@ -53,16 +51,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -77,9 +71,7 @@ import com.sulkta.straw.OverlayChromeColor
import com.sulkta.straw.feature.detail.VideoDetailViewModel
import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.util.strawLogI
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(UnstableApi::class)
@Composable
@ -98,13 +90,6 @@ fun PlayerScreen(
var audioOnly by remember { mutableStateOf(false) }
var showSpeedDialog by remember { mutableStateOf(false) }
// Drag-to-minimize: vertical offset accumulated during the gesture.
// On release past the threshold we dismiss into the minibar.
val density = LocalDensity.current
val dismissThresholdPx = with(density) { 200.dp.toPx() }
val dragY = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
// When the resolved playback for this URL is ready, push it into the
// shared controller — unless it's already playing this exact URL, in
// which case do nothing: the player is already where we want it. The
@ -144,28 +129,7 @@ fun PlayerScreen(
val activity = context as? Activity
Box(
modifier = Modifier
.fillMaxSize()
.offset { IntOffset(0, dragY.value.roundToInt()) }
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragEnd = {
if (dragY.value > dismissThresholdPx) {
onMinimize()
} else {
scope.launch { dragY.animateTo(0f, tween(180)) }
}
},
onDragCancel = {
scope.launch { dragY.animateTo(0f, tween(180)) }
},
onVerticalDrag = { _, dy ->
scope.launch {
dragY.snapTo((dragY.value + dy).coerceAtLeast(0f))
}
},
)
},
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
when {