diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index fdfb000d4..a20dca091 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt index 9763aa727..1b57d3471 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt @@ -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 diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index dea41ffea..2a5cc65e9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -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)) }, ) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 499f232b1..7b552764e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -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" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index cdc2fa39a..4266cf34b 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -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 {