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
5ced0f00c4
commit
9e8979699f
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
|
// 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 = 26
|
const val STRAW_VERSION_CODE = 27
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-AL"
|
const val STRAW_VERSION_NAME = "0.1.0-AM"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,17 @@ class Navigator(initial: Screen) {
|
||||||
stack.removeAt(stack.lastIndex)
|
stack.removeAt(stack.lastIndex)
|
||||||
return true
|
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
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -108,13 +108,18 @@ class StrawActivity : ComponentActivity() {
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
ScreenContent(nav, s = nav.current)
|
ScreenContent(nav, s = nav.current)
|
||||||
// Persistent minibar — visible on every non-Player
|
// The minibar is the takeover-when-you-leave UI:
|
||||||
// screen whenever something is loaded.
|
// hide it while you're on the actual video page
|
||||||
if (nav.current !is Screen.Player) {
|
// (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(
|
MinibarOverlay(
|
||||||
onExpand = {
|
onExpand = {
|
||||||
val item = NowPlaying.current.value ?: return@MinibarOverlay
|
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),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
)
|
)
|
||||||
|
|
@ -157,6 +162,7 @@ class StrawActivity : ComponentActivity() {
|
||||||
streamUrl = s.streamUrl,
|
streamUrl = s.streamUrl,
|
||||||
initialTitle = s.title,
|
initialTitle = s.title,
|
||||||
onPlay = { nav.push(Screen.Player(s.streamUrl, 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)) },
|
onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
|
||||||
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
|
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,19 @@
|
||||||
|
|
||||||
package com.sulkta.straw.feature.detail
|
package com.sulkta.straw.feature.detail
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.PictureInPictureParams
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Rational
|
||||||
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.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||||
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
|
||||||
|
|
@ -30,6 +38,8 @@ import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.PlayArrow
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
|
|
@ -50,19 +60,25 @@ import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.TrackSelectionParameters
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import coil3.compose.AsyncImage
|
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.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
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class, UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun VideoDetailScreen(
|
fun VideoDetailScreen(
|
||||||
streamUrl: String,
|
streamUrl: String,
|
||||||
initialTitle: String,
|
initialTitle: String,
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
|
onMinimize: () -> Unit,
|
||||||
onOpenChannel: (channelUrl: String, name: String) -> Unit,
|
onOpenChannel: (channelUrl: String, name: String) -> Unit,
|
||||||
onOpenVideo: (url: String, title: String) -> Unit,
|
onOpenVideo: (url: String, title: String) -> Unit,
|
||||||
vm: VideoDetailViewModel = viewModel(),
|
vm: VideoDetailViewModel = viewModel(),
|
||||||
) {
|
) {
|
||||||
val state by vm.ui.collectAsStateWithLifecycle()
|
val state by vm.ui.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val controller = LocalStrawController.current
|
||||||
|
val activity = context as? Activity
|
||||||
var showDownloadDialog by remember { mutableStateOf(false) }
|
var showDownloadDialog by remember { mutableStateOf(false) }
|
||||||
var showSaveToPlaylistDialog by remember { mutableStateOf(false) }
|
var showSaveToPlaylistDialog by remember { mutableStateOf(false) }
|
||||||
// Inline-play state resets when navigating to a different video.
|
// Inline-play state resets when navigating to a different video.
|
||||||
var inlinePlaying by remember(streamUrl) { mutableStateOf(false) }
|
var inlinePlaying by remember(streamUrl) { mutableStateOf(false) }
|
||||||
LaunchedEffect(streamUrl) { vm.load(streamUrl) }
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.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()
|
.statusBarsPadding()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
|
|
@ -118,6 +157,33 @@ fun VideoDetailScreen(
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val d = state.detail ?: return@Column
|
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) {
|
if (inlinePlaying) {
|
||||||
InlinePlayer(
|
InlinePlayer(
|
||||||
streamUrl = streamUrl,
|
streamUrl = streamUrl,
|
||||||
|
|
@ -129,7 +195,8 @@ fun VideoDetailScreen(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(16f / 9f)
|
.aspectRatio(16f / 9f)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(Color.Black),
|
.background(Color.Black)
|
||||||
|
.then(playerDragModifier),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -137,7 +204,8 @@ fun VideoDetailScreen(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(16f / 9f)
|
.aspectRatio(16f / 9f)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable { inlinePlaying = true },
|
.clickable { inlinePlaying = true }
|
||||||
|
.then(playerDragModifier),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
|
|
@ -219,6 +287,94 @@ fun VideoDetailScreen(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Button(onClick = onPlay) { Text("Play") }
|
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 = {
|
OutlinedButton(onClick = {
|
||||||
val send = Intent(Intent.ACTION_SEND).apply {
|
val send = Intent(Intent.ACTION_SEND).apply {
|
||||||
type = "text/plain"
|
type = "text/plain"
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@
|
||||||
*
|
*
|
||||||
* Fullscreen player surface. The player itself lives in PlaybackService
|
* Fullscreen player surface. The player itself lives in PlaybackService
|
||||||
* (one ExoPlayer for the whole app); this composable is a thin shell that
|
* (one ExoPlayer for the whole app); this composable is a thin shell that
|
||||||
* renders a PlayerView bound to the shared MediaController, lets the user
|
* renders a PlayerView bound to the shared MediaController and overlays
|
||||||
* drag down to dismiss into the minibar, and overlays speed / audio-only
|
* speed / audio-only / share / PiP / minimize controls. To minimize, tap
|
||||||
* / share / PiP / minimize controls. SponsorBlock auto-skip lives at the
|
* the down-arrow button (top right) — the swipe-down gesture lives on
|
||||||
* activity root in [SponsorBlockSkipLoop].
|
* 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
|
package com.sulkta.straw.feature.player
|
||||||
|
|
@ -19,17 +21,13 @@ import android.os.Build
|
||||||
import android.util.Rational
|
import android.util.Rational
|
||||||
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.tween
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
|
||||||
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.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
@ -53,16 +51,12 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.feature.detail.VideoDetailViewModel
|
||||||
import com.sulkta.straw.net.SbSegment
|
import com.sulkta.straw.net.SbSegment
|
||||||
import com.sulkta.straw.util.strawLogI
|
import com.sulkta.straw.util.strawLogI
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -98,13 +90,6 @@ fun PlayerScreen(
|
||||||
var audioOnly by remember { mutableStateOf(false) }
|
var audioOnly by remember { mutableStateOf(false) }
|
||||||
var showSpeedDialog 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
|
// When the resolved playback for this URL is ready, push it into the
|
||||||
// shared controller — unless it's already playing this exact URL, in
|
// shared controller — unless it's already playing this exact URL, in
|
||||||
// which case do nothing: the player is already where we want it. The
|
// which case do nothing: the player is already where we want it. The
|
||||||
|
|
@ -144,28 +129,7 @@ fun PlayerScreen(
|
||||||
val activity = context as? Activity
|
val activity = context as? Activity
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.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))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue