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:
parent
885398e3bd
commit
35f5affec3
5 changed files with 189 additions and 52 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue