Expandable player morph: one container, video page ⇄ minibar (vc=75)
Some checks failed
build-apk / build-and-publish (push) Failing after 45s
gitleaks / scan (push) Successful in 37s

Replace the separate Screen.VideoDetail page + MinibarOverlay with one
ExpandablePlayer container that morphs continuously between the full
video page and the bottom minibar, in both directions. The old flow just
made the minibar appear/vanish; this is a true shared-element transition.

- One fraction (0=minibar, 1=full page) drives a graphicsLayer
  scale+translate on a single mounted TextureView PlayerView. The
  transform runs in the render phase (reads the Animatable inside the
  layer block) so the morph is smooth without recomposing the detail
  body, and the same video surface stays live across the whole range.
- 100dp collapsed player is 16:9, same as expanded, so the morph is a
  pure uniform scale (no aspect distortion).
- Opening a video sets OpenVideo + expands instead of pushing a screen;
  the browse screen stays underneath so collapsing returns you there.
- New OpenVideo singleton (open video) distinct from NowPlaying (playing
  video); the two are kept in sync while collapsed so autoplay-next
  doesn't leave the open page stale.
- VideoDetailBody extracted from VideoDetailScreen; the inline player
  surface + resolve/play wiring became InlinePlayerSurface inside
  ExpandablePlayer. VideoDetailScreen + MinibarOverlay deleted.
- Back: fullscreen pops, then expanded collapses, then browse stack.
- Unchanged: shared controller, NowPlaying, setPlayingFrom, SponsorBlock,
  autoplay-next, PiP, background audio, and true-fullscreen Player (⛶).
This commit is contained in:
Cobb 2026-06-20 12:17:41 -07:00
parent e2723adc71
commit 7b28d94189
8 changed files with 1294 additions and 1211 deletions

View file

@ -9,6 +9,23 @@ const val STRAW_SDK_TARGET = 35
// Sulkta fork — Straw
//
// vc=75 / 0.1.0-CI — expandable player (full rearchitect):
// * The video page and the bottom minibar are now ONE container that
// morphs continuously between them, both directions. Replaces the
// old separate Screen.VideoDetail page + MinibarOverlay (which just
// appeared/vanished). One fraction (0=minibar, 1=full page) drives a
// graphicsLayer scale+translate on a single mounted TextureView, so
// the morph runs in the render phase (smooth) and the same video
// surface stays live across the whole range — a true shared-element
// transition. Swipe the player down → it shrinks into the toolbar;
// swipe/tap the toolbar up → it grows back into the page.
// * Opening a video is no longer a nav push — it sets OpenVideo +
// expands. The browse screen underneath stays put, so collapsing
// drops you right back where you were.
// * Playback plumbing unchanged: shared controller, NowPlaying,
// setPlayingFrom, SponsorBlock, autoplay-next, PiP, background audio,
// and the true-fullscreen Player (⛶) all still key off NowPlaying.
//
// 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
@ -57,6 +74,6 @@ const val STRAW_SDK_TARGET = 35
// 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 = 74
const val STRAW_VERSION_NAME = "0.1.0-CH"
const val STRAW_VERSION_CODE = 75
const val STRAW_VERSION_NAME = "0.1.0-CI"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -18,7 +18,12 @@ sealed interface Screen {
data object Settings : Screen
data object Playlists : Screen
data object Downloads : Screen
data class VideoDetail(val streamUrl: String, val title: String) : Screen
// NOTE: there is no Screen.VideoDetail anymore. Opening a video is
// NOT a nav push — it sets OpenVideo + expands the activity-level
// ExpandablePlayer (the morphing video⇄minibar container). The browse
// screen underneath stays on the stack so collapsing the player drops
// you right back where you were. Screen.Player is still a real
// destination: true fullscreen / landscape, pushed via the ⛶ button.
data class Player(val streamUrl: String, val title: String) : Screen
data class Channel(val channelUrl: String, val name: String) : Screen
data class PlaylistView(val playlistId: String, val name: String) : Screen
@ -43,16 +48,6 @@ class Navigator(initial: Screen) {
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

@ -22,17 +22,20 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.media3.common.util.UnstableApi
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.ThemeMode
import com.sulkta.straw.feature.channel.ChannelScreen
import com.sulkta.straw.feature.detail.VideoDetailScreen
import com.sulkta.straw.feature.download.DownloadsScreen
import com.sulkta.straw.feature.player.ExpandablePlayer
import com.sulkta.straw.feature.player.LocalStrawController
import com.sulkta.straw.feature.player.MinibarOverlay
import com.sulkta.straw.feature.player.NowPlaying
import com.sulkta.straw.feature.player.OpenVideo
import com.sulkta.straw.feature.player.OpenVideoItem
import com.sulkta.straw.feature.player.PlayerScreen
import com.sulkta.straw.feature.player.SponsorBlockSkipLoop
import com.sulkta.straw.feature.player.rememberStrawController
@ -84,13 +87,30 @@ class StrawActivity : ComponentActivity() {
MaterialTheme(colorScheme = scheme) {
CompositionLocalProvider(LocalStrawController provides controller) {
Surface(modifier = Modifier.fillMaxSize()) {
val initial: Screen =
if (startUrl != null) Screen.VideoDetail(startUrl, "") else Screen.Home
val nav = rememberNavigator(initial)
val nav = rememberNavigator(Screen.Home)
// The video currently open in the expandable player is
// activity-level state, NOT a nav destination. `expanded`
// is the logical "player is showing the full page" flag;
// drag releases inside ExpandablePlayer push the new
// target back via onTargetChange so back-button + state
// stay in sync.
var expanded by remember { mutableStateOf(false) }
val openVideo: (String, String) -> Unit = { url, title ->
OpenVideo.open(OpenVideoItem(url, title))
expanded = true
}
DisposableEffect(nav) {
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// Back order: the true-fullscreen Player pops
// first; an expanded player collapses to the
// minibar; otherwise pop the browse stack (or
// exit at root).
if (nav.current !is Screen.Player && expanded) {
expanded = false
return
}
if (!nav.pop()) {
isEnabled = false
this@StrawActivity.onBackPressedDispatcher.onBackPressed()
@ -101,36 +121,38 @@ class StrawActivity : ComponentActivity() {
onDispose { cb.remove() }
}
// Drain newly-arrived deep links. Consumed (cleared) once
// pushed so we don't re-navigate on every recomposition.
// Open the deep-linked video into the expandable player on
// first composition (instead of a VideoDetail nav push).
LaunchedEffect(Unit) {
if (startUrl != null) openVideo(startUrl, "")
}
// Drain newly-arrived deep links the same way. Cleared
// once consumed so we don't re-open on every recomposition.
val pending by pendingDeepLink.collectAsState()
LaunchedEffect(pending) {
val url = pending ?: return@LaunchedEffect
nav.push(Screen.VideoDetail(url, ""))
openVideo(url, "")
pendingDeepLink.value = null
}
// SponsorBlock skip loop runs at the activity level so it
// applies whether the user is fullscreen, in the minibar,
// or away from the player surface.
// applies whether the player is expanded, collapsed to the
// minibar, or away from the player surface.
SponsorBlockSkipLoop()
Box(modifier = Modifier.fillMaxSize()) {
ScreenContent(nav, s = nav.current)
// 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.VideoDetail(item.streamUrl, item.title))
},
modifier = Modifier.align(Alignment.BottomCenter),
ScreenContent(nav, s = nav.current, onOpenVideo = openVideo)
// The one expandable player: full video page ⇄
// minibar, morphing both ways. Present whenever a
// video is open; hidden only while the true-fullscreen
// Player screen is up (that surface IS the player).
if (nav.current !is Screen.Player) {
ExpandablePlayer(
expandedTarget = expanded,
onTargetChange = { expanded = it },
onFullscreen = { url, title -> nav.push(Screen.Player(url, title)) },
onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
onOpenVideo = openVideo,
)
}
}
@ -152,34 +174,30 @@ class StrawActivity : ComponentActivity() {
}
@Composable
private fun ScreenContent(nav: Navigator, s: Screen) {
private fun ScreenContent(
nav: Navigator,
s: Screen,
onOpenVideo: (url: String, title: String) -> Unit,
) {
when (s) {
is Screen.Home -> StrawHome(
onOpenSearch = { nav.push(Screen.Search) },
onOpenSettings = { nav.push(Screen.Settings) },
onOpenPlaylists = { nav.push(Screen.Playlists) },
onOpenDownloads = { nav.push(Screen.Downloads) },
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
onOpenVideo = onOpenVideo,
onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
)
is Screen.Downloads -> DownloadsScreen()
is Screen.Settings -> SettingsScreen()
is Screen.Search -> SearchScreen(
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
onOpenVideo = onOpenVideo,
onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
)
is Screen.VideoDetail -> VideoDetailScreen(
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)) },
)
is Screen.Channel -> ChannelScreen(
channelUrl = s.channelUrl,
initialName = s.name,
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
onOpenVideo = onOpenVideo,
)
is Screen.Player -> PlayerScreen(
streamUrl = s.streamUrl,
@ -192,7 +210,7 @@ class StrawActivity : ComponentActivity() {
is Screen.PlaylistView -> PlaylistViewScreen(
playlistId = s.playlistId,
initialName = s.name,
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
onOpenVideo = onOpenVideo,
)
}
}

View file

@ -0,0 +1,603 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The scrollable detail body that sits BELOW the player in the
* expandable player (vc=75). This used to be the bottom two-thirds of
* VideoDetailScreen; the player surface and the swipe-to-minimize drag
* moved up into ExpandablePlayer, which now owns the morph. This file is
* just the content: title, channel + subscribe, stats, watch-count,
* action pills, collapsible Details, recommendations.
*
* It reads the shared activity-scoped VideoDetailViewModel the same
* instance ExpandablePlayer drives `vm.load()` on so it renders the
* metadata for whatever video is currently open. `topPadding` reserves
* the vertical space the player occupies above it.
*/
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.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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.rememberScrollState
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.Download
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Headphones
import androidx.compose.material.icons.filled.PictureInPictureAlt
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.C
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.util.UnstableApi
import coil3.compose.AsyncImage
import com.sulkta.straw.data.ChannelRef
import com.sulkta.straw.data.History
import com.sulkta.straw.data.PlaylistItem
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.download.DownloadKind
import com.sulkta.straw.feature.download.Downloader
import com.sulkta.straw.feature.player.LocalStrawController
import com.sulkta.straw.feature.player.NowPlaying
import com.sulkta.straw.feature.player.VideoThumbnail
import com.sulkta.straw.feature.player.setPlayingFrom
import com.sulkta.straw.feature.playlist.SaveToPlaylistDialog
import com.sulkta.straw.feature.playlist.VideoActionTarget
import com.sulkta.straw.feature.playlist.VideoActionsSheet
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
/**
* Scroll body for the open video. [topPadding] is the room the player
* occupies above this content; [onCollapse] minimizes the expandable
* player (used by the Audio pill, which kicks off background audio and
* tucks the player away).
*/
@OptIn(UnstableApi::class)
@Composable
fun VideoDetailBody(
streamUrl: String,
topPadding: Dp,
onOpenChannel: (channelUrl: String, name: String) -> Unit,
onOpenVideo: (url: String, title: String) -> Unit,
onCollapse: () -> 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) }
var actionTarget by remember { mutableStateOf<VideoActionTarget?>(null) }
actionTarget?.let { t ->
VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
}
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(topPadding))
when {
state.loading -> Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 64.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
state.error != null -> Text(
"error: ${state.error}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
else -> {
val d = state.detail
// On a fresh A → B navigation the shared VM holds A's
// detail for one frame before vm.load(B) resets. Gate on
// loadedUrl so we never render A's metadata under B.
if (d == null || state.loadedUrl != streamUrl) {
Box(
modifier = Modifier.fillMaxWidth().padding(top = 64.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
} else {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Text(
text = d.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
val uploaderUrl = d.uploaderUrl
val subs by Subscriptions.get().subs.collectAsStateWithLifecycle()
val isSubscribed = uploaderUrl != null && subs.any { it.url == uploaderUrl }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
if (!d.uploaderAvatar.isNullOrBlank()) {
AsyncImage(
model = d.uploaderAvatar,
contentDescription = null,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.then(
if (uploaderUrl != null)
Modifier.clickable { onOpenChannel(uploaderUrl, d.uploader) }
else Modifier,
),
)
Spacer(modifier = Modifier.width(10.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = d.uploader,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = if (uploaderUrl != null) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = if (uploaderUrl != null) Modifier
.clickable { onOpenChannel(uploaderUrl, d.uploader) }
.padding(vertical = 4.dp)
else Modifier.padding(vertical = 4.dp),
)
if (d.uploaderSubscriberCount > 0) {
Text(
text = "${formatCount(d.uploaderSubscriberCount)} subscribers",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (uploaderUrl != null) {
val onSubClick = {
Subscriptions.get().toggle(
ChannelRef(
url = uploaderUrl,
name = d.uploader,
avatar = d.uploaderAvatar,
),
)
}
if (isSubscribed) {
OutlinedButton(onClick = onSubClick) { Text("Subscribed") }
} else {
Button(onClick = onSubClick) { Text("Subscribe") }
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AssistChip(
onClick = {},
label = { Text(formatViews(d.viewCount)) },
)
d.ryd?.let { ryd ->
AssistChip(
onClick = {},
label = { Text("👍 ${formatCount(ryd.likes)}") },
colors = AssistChipDefaults.assistChipColors(
labelColor = Color(0xFF2E7D32),
),
)
AssistChip(
onClick = {},
label = { Text("👎 ${formatCount(ryd.dislikes)}") },
colors = AssistChipDefaults.assistChipColors(
labelColor = Color(0xFFC62828),
),
)
}
if (d.sbSegmentCount > 0) {
AssistChip(
onClick = {},
label = { Text("${d.sbSegmentCount} skip${if (d.sbSegmentCount == 1) "" else "s"}") },
)
}
}
// "Watched N times" — our own play count, under the
// view count (vc=74). Sourced from HistoryStore by id.
val watchedVideoId = extractYtVideoId(streamUrl)
val watchHist by History.get().watches.collectAsState()
val plays = remember(watchHist, watchedVideoId) {
if (watchedVideoId == null) 0
else watchHist.firstOrNull { it.videoId == watchedVideoId }?.playCount ?: 0
}
if (plays > 0) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "▶ Watched $plays time${if (plays == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(16.dp))
// Action bar — uniform tonal pills in a single
// horizontally-scrollable row. Play/fullscreen live on
// the player surface above, so no standalone Play here.
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ActionPill(Icons.Filled.Headphones, "Audio") {
val c = controller
if (c == null) {
Toast.makeText(context, "no player", Toast.LENGTH_SHORT).show()
} else {
val r = state.resolved
if (NowPlaying.current.value?.streamUrl != streamUrl && r == null) {
Toast.makeText(context, "stream not ready", Toast.LENGTH_SHORT).show()
} else {
if (NowPlaying.current.value?.streamUrl != streamUrl && r != null) {
c.setPlayingFrom(
streamUrl = streamUrl,
title = d.title,
uploader = d.uploader,
thumbnail = d.thumbnail,
resolved = r,
uploaderUrl = d.uploaderUrl,
)
}
c.trackSelectionParameters = TrackSelectionParameters.Builder(context)
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
.build()
if (!c.isPlaying) c.play()
onCollapse()
}
}
}
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 -> {
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 {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, streamUrl)
putExtra(Intent.EXTRA_SUBJECT, d.title)
}
context.startActivity(Intent.createChooser(send, "Share video"))
}
ActionPill(Icons.Filled.Download, "Download") { showDownloadDialog = true }
ActionPill(Icons.Filled.PlaylistAdd, "Save") { showSaveToPlaylistDialog = true }
}
Spacer(modifier = Modifier.height(20.dp))
// Collapsible "Details" — description, rolled up by
// default, just above the recommendations.
var detailsExpanded by remember(streamUrl) { mutableStateOf(false) }
Row(
modifier = Modifier
.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(),
) {
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()) {
Spacer(modifier = Modifier.height(24.dp))
Text(
"Recommended",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
d.related.take(20).forEach { rel ->
RelatedRow(
item = rel,
onClick = { onOpenVideo(rel.url, rel.title) },
onLongClick = {
actionTarget = VideoActionTarget(
streamUrl = rel.url,
title = rel.title,
uploader = rel.uploader,
thumbnail = rel.thumbnail,
)
},
)
HorizontalDivider()
}
}
if (d.moreFromChannel.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
Text(
if (d.uploader.isBlank()) "More from this channel"
else "More from ${d.uploader}",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
d.moreFromChannel.take(20).forEach { item ->
RelatedRow(
item = item,
onClick = { onOpenVideo(item.url, item.title) },
onLongClick = {
actionTarget = VideoActionTarget(
streamUrl = item.url,
title = item.title,
uploader = item.uploader,
thumbnail = item.thumbnail,
)
},
)
HorizontalDivider()
}
}
if (showSaveToPlaylistDialog) {
SaveToPlaylistDialog(
item = PlaylistItem(
streamUrl = streamUrl,
title = d.title,
thumbnail = d.thumbnail,
uploader = d.uploader,
),
onDismiss = { showSaveToPlaylistDialog = false },
)
}
if (showDownloadDialog) {
val info = state.streamInfo
AlertDialog(
onDismissRequest = { showDownloadDialog = false },
title = { Text("Download") },
text = {
Column {
Text("Pick a format:", style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Saves to Android/data/.../files/Movies/. Visible in any file manager.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val audio = info?.audioOnly?.maxByOrNull { it.bitrate }?.url
if (audio != null) {
val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
val msg = if (id > 0) "audio queued" else "download refused (bad URL)"
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
}
showDownloadDialog = false
}) { Text("Audio") }
Button(onClick = {
val video = info?.combined?.maxByOrNull { it.bitrate }?.url
?: info?.videoOnly?.maxByOrNull { it.bitrate }?.url
if (video != null) {
val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video)
val msg = if (id > 0) "video queued" else "download refused (bad URL)"
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "no video stream", Toast.LENGTH_SHORT).show()
}
showDownloadDialog = false
}) { Text("Video") }
}
},
dismissButton = {
TextButton(onClick = { showDownloadDialog = false }) {
Text("Cancel")
}
},
)
}
}
}
}
}
// Clear the system nav bar so the last related row isn't tucked
// under the gesture pill.
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RelatedRow(
item: StreamItem,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.Top,
) {
VideoThumbnail(
thumbnail = item.thumbnail,
videoUrl = item.url,
durationSeconds = item.durationSeconds,
modifier = Modifier
.width(140.dp)
.height(80.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
val meta = buildString {
if (item.uploader.isNotBlank()) append(item.uploader)
if (item.viewCount > 0) {
if (isNotEmpty()) append(" · ")
append(formatViews(item.viewCount))
}
if (item.uploadDateRelative.isNotBlank()) {
if (isNotEmpty()) append(" · ")
append(item.uploadDateRelative)
}
}
if (meta.isNotEmpty()) {
Text(
text = meta,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** One action-bar pill: a tonal button with a leading icon + short label. */
@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)
}
}

View file

@ -1,957 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*/
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.view.LayoutInflater
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
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.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.rememberScrollState
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.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.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
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
import com.sulkta.straw.OverlayChromeColor
import com.sulkta.straw.OverlayDimColor
import com.sulkta.straw.R
import com.sulkta.straw.data.PlaylistItem
import com.sulkta.straw.feature.playlist.SaveToPlaylistDialog
import com.sulkta.straw.feature.download.DownloadKind
import com.sulkta.straw.feature.download.Downloader
import com.sulkta.straw.feature.player.LocalStrawController
import com.sulkta.straw.feature.player.NowPlaying
import com.sulkta.straw.feature.player.VideoThumbnail
import com.sulkta.straw.feature.player.setPlayingFrom
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.LogDump
import com.sulkta.straw.data.ChannelRef
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.util.formatCount
import com.sulkta.straw.util.formatViews
import com.sulkta.straw.util.stripHtml
@OptIn(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) }
var actionTarget by remember { mutableStateOf<com.sulkta.straw.feature.playlist.VideoActionTarget?>(null) }
actionTarget?.let { t ->
com.sulkta.straw.feature.playlist.VideoActionsSheet(
target = t,
onDismiss = { actionTarget = null },
)
}
// Inline-play state resets when navigating to a different video.
// Defaults to TRUE when:
// * the shared MediaController is already streaming this URL
// (back-from-fullscreen — without this the page renders as
// "freshly loaded" while audio keeps playing in the
// background), or
// * the user has Settings → Auto-start playback enabled (cold
// open from search / subs / wherever immediately plays).
// Off + fresh URL → thumbnail + Play overlay, user taps to start.
val autoStart by Settings.get().autoStartPlayback.collectAsState()
var inlinePlaying by remember(streamUrl) {
mutableStateOf(
NowPlaying.current.value?.streamUrl == streamUrl || autoStart,
)
}
LaunchedEffect(streamUrl) { vm.load(streamUrl) }
// The Background button (and the fullscreen audio-only toggle)
// disable the video track on the shared controller, and that state
// sticks. Entering detail = user wants to watch the video — wipe the
// override and let DASH pick the highest renderable video again.
LaunchedEffect(controller, streamUrl) {
controller?.let {
it.trackSelectionParameters = TrackSelectionParameters.Builder(context).build()
}
}
// Swipe-down to minimize. The drag handle is the inline player surface
// at the top of the page; we translate the WHOLE page with it so the
// motion reads as "the video is being tucked away" rather than "this
// one widget slid."
//
// Two-state pattern so the drag stays smooth at 120fps:
// liveDrag — mutableFloatStateOf updated SYNCHRONOUSLY in
// rememberDraggableState's callback. One state write
// per pointer event, no coroutine spawn.
// releaseAnim — Animatable driven by a single coroutine that
// runs only when the finger leaves (spring back
// if short, slide off-screen + onMinimize if past
// threshold or flung).
// graphicsLayer reads whichever is active via the `dragging` flag.
// The old single-Animatable / scope.launch-per-pixel pattern
// raced coroutines for every drag delta and stuttered on fast
// gestures; this doesn't.
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val dismissThresholdPx = with(density) { 140.dp.toPx() }
val flingVelocityThreshold = with(density) { 600.dp.toPx() }
val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() }
// mutableFloatStateOf avoids boxing on every drag delta — the
// draggable callback fires 100+ times/s on a fast swipe.
var liveDrag by remember { mutableFloatStateOf(0f) }
var dragging by remember { mutableStateOf(false) }
val releaseAnim = remember { Animatable(0f) }
val draggableState = rememberDraggableState { delta ->
liveDrag = (liveDrag + delta).coerceAtLeast(0f)
}
val playerDragModifier = Modifier.draggable(
orientation = Orientation.Vertical,
state = draggableState,
onDragStarted = {
releaseAnim.stop()
liveDrag = releaseAnim.value
dragging = true
},
onDragStopped = { velocity ->
val shouldDismiss =
liveDrag > dismissThresholdPx || velocity > flingVelocityThreshold
releaseAnim.snapTo(liveDrag)
dragging = false
if (shouldDismiss) {
// Slide the rest of the way off-screen, then pop. The
// pop happens AFTER the animation so the user sees the
// page leave under their finger instead of a hard cut.
releaseAnim.animateTo(
screenHeightPx,
tween(durationMillis = 220, easing = FastOutLinearInEasing),
)
onMinimize()
} else {
releaseAnim.animateTo(
0f,
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow,
),
)
}
liveDrag = 0f
},
)
Column(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
val y = if (dragging) liveDrag else releaseAnim.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()),
) {
when {
state.loading -> Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 64.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
state.error != null -> Text(
"error: ${state.error}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
else -> {
val d = state.detail ?: return@Column
// Guard against vm's activity-scoped staleness — on a
// fresh navigation A → B, the shared VM still holds
// A's detail/resolved for one composition frame before
// vm.load(B)'s reset propagates. Without this gate, the
// InlinePlayer's LaunchedEffect would fire with
// streamUrl=B but resolved=A's URLs and play A under
// B's chrome — symptom is the detail page showing the
// new video while the audio is still the old one.
if (state.loadedUrl != streamUrl) return@Column
// Player surface — edge-to-edge, NewPipe/YouTube style.
// Lives outside the 16dp horizontal padding so the
// thumbnail fills the screen width with no gutters.
if (inlinePlaying) {
InlinePlayer(
streamUrl = streamUrl,
title = d.title,
uploader = d.uploader,
thumbnail = d.thumbnail,
onFullscreen = onPlay,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(Color.Black)
.then(playerDragModifier),
)
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(Color.Black)
.clickable { inlinePlaying = true }
.then(playerDragModifier),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = d.thumbnail,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
Box(
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(OverlayDimColor),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = Color.White,
modifier = Modifier.size(40.dp),
)
}
}
}
// Everything below the player gets the side gutters
// back; player itself remains edge-to-edge.
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Text(
text = d.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
val uploaderUrl = d.uploaderUrl
// Channel row: avatar + name (larger, clickable when we
// have a uploaderUrl) + Subscribe / Subscribed toggle.
// Matches the YouTube/NewPipe layout below the title.
val subs by Subscriptions.get().subs.collectAsStateWithLifecycle()
val isSubscribed = uploaderUrl != null && subs.any { it.url == uploaderUrl }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
if (!d.uploaderAvatar.isNullOrBlank()) {
AsyncImage(
model = d.uploaderAvatar,
contentDescription = null,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.then(
if (uploaderUrl != null)
Modifier.clickable { onOpenChannel(uploaderUrl, d.uploader) }
else Modifier
),
)
Spacer(modifier = Modifier.width(10.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = d.uploader,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = if (uploaderUrl != null) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = if (uploaderUrl != null) Modifier
.clickable { onOpenChannel(uploaderUrl, d.uploader) }
.padding(vertical = 4.dp)
else Modifier.padding(vertical = 4.dp),
)
if (d.uploaderSubscriberCount > 0) {
Text(
text = "${formatCount(d.uploaderSubscriberCount)} subscribers",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (uploaderUrl != null) {
val onSubClick = {
Subscriptions.get().toggle(
ChannelRef(
url = uploaderUrl,
name = d.uploader,
avatar = d.uploaderAvatar,
),
)
}
if (isSubscribed) {
OutlinedButton(onClick = onSubClick) { Text("Subscribed") }
} else {
Button(onClick = onSubClick) { Text("Subscribe") }
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AssistChip(
onClick = {},
label = { Text(formatViews(d.viewCount)) },
)
d.ryd?.let { ryd ->
AssistChip(
onClick = {},
label = { Text("👍 ${formatCount(ryd.likes)}") },
colors = AssistChipDefaults.assistChipColors(
labelColor = Color(0xFF2E7D32),
),
)
AssistChip(
onClick = {},
label = { Text("👎 ${formatCount(ryd.dislikes)}") },
colors = AssistChipDefaults.assistChipColors(
labelColor = Color(0xFFC62828),
),
)
}
if (d.sbSegmentCount > 0) {
AssistChip(
onClick = {},
label = { Text("${d.sbSegmentCount} skip${if (d.sbSegmentCount == 1) "" else "s"}") },
)
}
}
// "Watched N times" — our own play count, sitting under the
// view count (vc=74). collectAsState is called unconditionally
// (stable composable call); only the Text is gated, and only
// once we've actually played it. Sourced from HistoryStore by
// this video's id.
val watchedVideoId = extractYtVideoId(streamUrl)
val watchHist by com.sulkta.straw.data.History.get().watches.collectAsState()
val plays = remember(watchHist, watchedVideoId) {
if (watchedVideoId == null) 0
else watchHist.firstOrNull { it.videoId == watchedVideoId }?.playCount ?: 0
}
if (plays > 0) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "▶ Watched $plays time${if (plays == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(16.dp))
// Action bar — uniform tonal pills in a single horizontally
// scrollable row so they never wrap into a ragged block. The
// 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),
) {
ActionPill(Icons.Filled.Headphones, "Audio") {
val c = controller
if (c == null) {
Toast.makeText(context, "no player", Toast.LENGTH_SHORT).show()
} else {
val r = state.resolved
// 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()
} else {
if (NowPlaying.current.value?.streamUrl != streamUrl && r != null) {
c.setPlayingFrom(
streamUrl = streamUrl,
title = d.title,
uploader = d.uploader,
thumbnail = d.thumbnail,
resolved = r,
uploaderUrl = d.uploaderUrl,
)
}
// Audio-only: drop the video track. The foreground
// 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)
.build()
if (!c.isPlaying) c.play()
onMinimize()
}
}
}
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 {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, streamUrl)
putExtra(Intent.EXTRA_SUBJECT, d.title)
}
context.startActivity(Intent.createChooser(send, "Share video"))
}
ActionPill(Icons.Filled.Download, "Download") { showDownloadDialog = true }
ActionPill(Icons.Filled.PlaylistAdd, "Save") { showSaveToPlaylistDialog = true }
}
Spacer(modifier = Modifier.height(20.dp))
// Collapsible "Details" — the description, rolled up by
// default and sitting just above the recommendations. Resets
// to collapsed on each new video (keyed by streamUrl).
var detailsExpanded by remember(streamUrl) { mutableStateOf(false) }
Row(
modifier = Modifier
.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()) {
Spacer(modifier = Modifier.height(24.dp))
Text(
"Recommended",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
d.related.take(20).forEach { rel ->
RelatedRow(
item = rel,
onClick = { onOpenVideo(rel.url, rel.title) },
onLongClick = {
actionTarget = com.sulkta.straw.feature.playlist.VideoActionTarget(
streamUrl = rel.url,
title = rel.title,
uploader = rel.uploader,
thumbnail = rel.thumbnail,
)
},
)
HorizontalDivider()
}
}
if (d.moreFromChannel.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
Text(
if (d.uploader.isBlank()) "More from this channel"
else "More from ${d.uploader}",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
d.moreFromChannel.take(20).forEach { item ->
RelatedRow(
item = item,
onClick = { onOpenVideo(item.url, item.title) },
onLongClick = {
actionTarget = com.sulkta.straw.feature.playlist.VideoActionTarget(
streamUrl = item.url,
title = item.title,
uploader = item.uploader,
thumbnail = item.thumbnail,
)
},
)
HorizontalDivider()
}
}
if (showSaveToPlaylistDialog) {
SaveToPlaylistDialog(
item = PlaylistItem(
streamUrl = streamUrl,
title = d.title,
thumbnail = d.thumbnail,
uploader = d.uploader,
),
onDismiss = { showSaveToPlaylistDialog = false },
)
}
if (showDownloadDialog) {
val info = state.streamInfo
AlertDialog(
onDismissRequest = { showDownloadDialog = false },
title = { Text("Download") },
text = {
Column {
Text("Pick a format:", style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Saves to Android/data/.../files/Movies/. Visible in any file manager.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val audio = info?.audioOnly?.maxByOrNull { it.bitrate }?.url
if (audio != null) {
val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
val msg = if (id > 0) "audio queued" else "download refused (bad URL)"
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "no audio stream", Toast.LENGTH_SHORT).show()
}
showDownloadDialog = false
}) { Text("Audio") }
Button(onClick = {
val video = info?.combined?.maxByOrNull { it.bitrate }?.url
?: info?.videoOnly?.maxByOrNull { it.bitrate }?.url
if (video != null) {
val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video)
val msg = if (id > 0) "video queued" else "download refused (bad URL)"
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "no video stream", Toast.LENGTH_SHORT).show()
}
showDownloadDialog = false
}) { Text("Video") }
}
},
dismissButton = {
TextButton(onClick = { showDownloadDialog = false }) {
Text("Cancel")
}
},
)
}
} // close inner Column (padded body)
}
}
// Leave room at the bottom for the system nav bar so the last
// related video doesn't tuck under the gesture pill / 3-button
// nav. Compose's `navigationBarsPadding` would push the whole
// surface up; we want the scroll to extend past it instead.
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RelatedRow(
item: StreamItem,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.Top,
) {
VideoThumbnail(
thumbnail = item.thumbnail,
videoUrl = item.url,
durationSeconds = item.durationSeconds,
modifier = Modifier
.width(140.dp)
.height(80.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
// Build the metadata line from whatever's available.
// channelInfo-sourced items (More from channel) come back
// with uploader="" because the channel page doesn't repeat
// the uploader name on each row — it's implicit. Skip
// empty pieces with the leading-separator dance so we
// never end up with " · viewCount" or trailing dots.
// Earlier shape was leaving an empty metadata line on
// More-from-channel rows.
val meta = buildString {
if (item.uploader.isNotBlank()) append(item.uploader)
if (item.viewCount > 0) {
if (isNotEmpty()) append(" · ")
append(formatViews(item.viewCount))
}
if (item.uploadDateRelative.isNotBlank()) {
if (isNotEmpty()) append(" · ")
append(item.uploadDateRelative)
}
}
if (meta.isNotEmpty()) {
Text(
text = meta,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/**
* 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
* a PlayerView bound to the shared LocalStrawController the same
* player as the fullscreen PlayerScreen and the minibar overlay. The
* pill hops to fullscreen; playback continues unchanged. There is
* nothing to release here: the controller is process-wide, and the
* PlayerView's surface is detached on dispose via onRelease.
*/
@OptIn(UnstableApi::class)
@Composable
private fun InlinePlayer(
streamUrl: String,
title: String,
uploader: String,
thumbnail: String?,
onFullscreen: () -> Unit,
modifier: Modifier = Modifier,
) {
val controller = LocalStrawController.current
val vm: VideoDetailViewModel = viewModel()
val state by vm.ui.collectAsStateWithLifecycle()
// Push the resolved stream into the shared controller if it isn't
// already playing this URL. We don't kick off a new fetch — the
// outer VideoDetailScreen already called vm.load(streamUrl).
//
// retryVersion lets the user manually re-fire setPlayingFrom after
// a playback error. Without it, the screen used to lock into the
// thumbnail+spinner branch once NowPlaying.clear() fired from
// onPlayerError.
val resolved = state.resolved
var retryVersion by remember(streamUrl) { mutableIntStateOf(0) }
LaunchedEffect(controller, resolved, streamUrl, retryVersion) {
val c = controller ?: return@LaunchedEffect
val r = resolved ?: return@LaunchedEffect
// Optimization, not safety. claim() guards the race.
if (NowPlaying.current.value?.streamUrl == streamUrl) return@LaunchedEffect
c.setPlayingFrom(
streamUrl = streamUrl,
title = title,
uploader = uploader,
thumbnail = thumbnail,
resolved = r,
uploaderUrl = state.detail?.uploaderUrl,
)
}
var playbackError by remember { mutableStateOf<String?>(null) }
DisposableEffect(controller) {
val c = controller
val listener = object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
// Scrub the message — Media3's HttpDataSource exceptions
// include the full signed URL in.message.
val raw = error.message ?: "(no message)"
playbackError = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}"
// Clear NowPlaying so the minibar drops the dead
// session.
NowPlaying.clear()
}
}
c?.addListener(listener)
onDispose { c?.removeListener(listener) }
}
// Track whether the shared controller has actually swapped over to
// THIS video's stream. Until it does (the brief window between
// streamInfo resolving and setPlayingFrom + setMediaItem landing),
// binding PlayerView to the controller would render the PREVIOUS
// video's frame under the new detail page — exactly the "new page,
// old video" bug.
val nowPlaying by NowPlaying.current.collectAsStateWithLifecycle()
val controllerOnThisVideo = nowPlaying?.streamUrl == streamUrl
Box(modifier = modifier, contentAlignment = Alignment.Center) {
when {
controller == null || state.loading -> CircularProgressIndicator(color = Color.White)
state.error != null -> Text(
"playback error: ${state.error}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
playbackError != null -> Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"playback error: $playbackError",
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = {
// Clear the error AND nudge the LaunchedEffect to
// re-attempt setPlayingFrom.
// without this the screen used to lock on the
// error forever after NowPlaying.clear().
playbackError = null
retryVersion += 1
}) { Text("Retry") }
}
resolved?.isPlayable != true -> Text(
"no playable stream",
color = Color.White,
modifier = Modifier.padding(16.dp),
)
// Stream resolved for THIS URL but the controller hasn't
// actually swapped media items yet — show the thumbnail
// with a spinner. Without this, the PlayerView below would
// bind to the controller and render the OUTGOING video's
// last frame while the new detail page chrome shows the
// new title/description. Bug reported 2026-05-26.
!controllerOnThisVideo -> {
if (!thumbnail.isNullOrBlank()) {
AsyncImage(
model = thumbnail,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
}
CircularProgressIndicator(color = Color.White)
}
else -> {
AndroidView(
factory = { ctx ->
// Inflate from XML to get a TEXTURE_VIEW surface. A
// SurfaceView is composited separately from the view
// hierarchy and does NOT follow a Compose 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 tree
// and translates in lockstep, so the dismiss is smooth.
// use_controller + keep_content_on_player_reset are set
// in the XML; the latter holds the last frame on dispose
// so the inline ↔ fullscreen transition doesn't flash
// black between detach + reattach.
(LayoutInflater.from(ctx)
.inflate(R.layout.inline_player_view, null) as PlayerView)
.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 },
onRelease = { it.player = null },
modifier = Modifier.fillMaxSize(),
)
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.background(OverlayChromeColor)
.clickable(onClick = onFullscreen),
contentAlignment = Alignment.Center,
) {
Text("", color = Color.White)
}
}
}
}
}

View file

@ -0,0 +1,561 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The expandable player (vc=75) ONE container that morphs continuously
* between the full video page (expanded) and the bottom minibar
* (collapsed), in both directions. Replaces the old separate
* Screen.VideoDetail page + MinibarOverlay, which "appeared from nowhere"
* instead of morphing.
*
* How the morph stays smooth: a single fraction (0 = minibar, 1 = full
* page) drives a graphicsLayer on each piece. graphicsLayer transforms
* run in the RENDER phase the Animatable's value is read inside the
* layer block, so frames update without recomposing the (heavy) detail
* body. The player surface is ONE TextureView-backed PlayerView that
* stays mounted across the whole range and is just scaled+translated, so
* there's no rebind / black flash a true shared-element morph.
*
* Geometry: collapsed player is a 100dp-wide 16:9 thumbnail in the
* bottom bar; expanded it's a full-width 16:9 at the top. 100dp:56.25dp
* is itself 16:9, so the morph is a pure uniform scale + translate (no
* aspect distortion of the video).
*
* Playback plumbing is unchanged: the shared MediaController, NowPlaying,
* setPlayingFrom, SponsorBlock, autoplay-next and the fullscreen
* Screen.Player (reached via ) all still key off NowPlaying + the
* controller. This file only changes how the detail page + minibar are
* presented.
*/
package com.sulkta.straw.feature.player
import android.view.LayoutInflater
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
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.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
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.util.lerp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerView
import coil3.compose.AsyncImage
import com.sulkta.straw.OverlayChromeColor
import com.sulkta.straw.OverlayDimColor
import com.sulkta.straw.R
import com.sulkta.straw.data.Settings
import com.sulkta.straw.feature.detail.VideoDetailBody
import com.sulkta.straw.feature.detail.VideoDetailViewModel
import com.sulkta.straw.util.LogDump
import kotlinx.coroutines.launch
private val ExpandSpec = tween<Float>(durationMillis = 300, easing = FastOutSlowInEasing)
private val CollapseSpec = tween<Float>(durationMillis = 260, easing = FastOutSlowInEasing)
/**
* Hosted in StrawActivity's root Box, above the browse screen. Visible
* whenever a video is open ([OpenVideo] non-null). [expandedTarget] is
* the activity-owned logical state (open/back/tap flip it); this drives
* the settle animation. Drag releases call [onTargetChange] to keep the
* activity in sync (so the back button knows whether to collapse).
*/
@OptIn(UnstableApi::class)
@Composable
fun ExpandablePlayer(
expandedTarget: Boolean,
onTargetChange: (Boolean) -> Unit,
onFullscreen: (streamUrl: String, title: String) -> Unit,
onOpenChannel: (channelUrl: String, name: String) -> Unit,
onOpenVideo: (url: String, title: String) -> Unit,
) {
val open by OpenVideo.current.collectAsState()
val cur = open ?: return
val controller = LocalStrawController.current
val context = LocalContext.current
val density = LocalDensity.current
val scope = rememberCoroutineScope()
val vm: VideoDetailViewModel = viewModel()
// Load the open video's metadata into the shared VM — the body reads
// the same instance.
LaunchedEffect(cur.streamUrl) { vm.load(cur.streamUrl) }
// Keep the OPEN video following the PLAYING video while collapsed.
// When autoplay-next or a notification skip changes NowPlaying out
// from under a minibar, the open video would otherwise go stale —
// expanding it would show the previous video's page under the new
// audio. We only follow while collapsed: when expanded the user is
// driving (a related-video tap calls openVideo explicitly), and on a
// fresh user-open `expandedTarget` is already true so this won't fight
// it before NowPlaying catches up.
val npSync by NowPlaying.current.collectAsState()
LaunchedEffect(npSync?.streamUrl, expandedTarget) {
val np = npSync ?: return@LaunchedEffect
if (!expandedTarget && np.streamUrl != cur.streamUrl) {
OpenVideo.open(OpenVideoItem(np.streamUrl, np.title))
}
}
// Entering a video means "watch the video": wipe any audio-only track
// override left by a prior Background/Audio action so DASH picks the
// best video track again.
LaunchedEffect(controller, cur.streamUrl) {
controller?.let {
it.trackSelectionParameters =
androidx.media3.common.TrackSelectionParameters.Builder(context).build()
}
}
// Fraction state. Start expanded — opening a video shows it full; the
// morph is what you see on collapse (swipe down) and re-expand.
val anim = remember { Animatable(1f) }
var dragging by remember { mutableStateOf(false) }
var liveDrag by remember { mutableFloatStateOf(1f) }
// Composition gates so we don't keep the heavy body composed (or the
// bar capturing touches) outside the states where each is visible.
var bodyVisible by remember { mutableStateOf(true) }
var fullyExpanded by remember { mutableStateOf(true) }
// External expand/collapse (open a video, tap the bar, system back).
LaunchedEffect(expandedTarget) {
if (dragging) return@LaunchedEffect
if (expandedTarget) {
bodyVisible = true
anim.animateTo(1f, ExpandSpec)
fullyExpanded = true
} else {
fullyExpanded = false
anim.animateTo(0f, CollapseSpec)
bodyVisible = false
}
}
// Play/pause icon state for the collapsed bar — listening is the only
// reliable way; isPlaying snapshots stale between events.
var isPlaying by remember { mutableStateOf(controller?.isPlaying ?: false) }
DisposableEffect(controller) {
val c = controller ?: return@DisposableEffect onDispose {}
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) { isPlaying = playing }
}
c.addListener(listener)
isPlaying = c.isPlaying
onDispose { c.removeListener(listener) }
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val wPx = constraints.maxWidth.toFloat()
val hPx = constraints.maxHeight.toFloat()
val statusTopPx = WindowInsets.statusBars.getTop(density).toFloat()
val navBottomPx = WindowInsets.navigationBars.getBottom(density).toFloat()
val expandedHpx = wPx * 9f / 16f
val barHpx = with(density) { 64.dp.toPx() }
val collapsedWpx = with(density) { 100.dp.toPx() }
val collapsedScale = (collapsedWpx / wPx).coerceIn(0.05f, 1f)
val collapsedHpx = expandedHpx * collapsedScale
val collapsedXpx = with(density) { 8.dp.toPx() }
val collapsedTopPx = hPx - navBottomPx - barHpx + (barHpx - collapsedHpx) / 2f
val playerBottomExpandedPx = statusTopPx + expandedHpx
val travelPx = (collapsedTopPx - statusTopPx).coerceAtLeast(1f)
val flingThreshPx = with(density) { 800.dp.toPx() }
// Shared vertical drag: down → collapse, up → expand. Used by both
// the player surface and the bar.
val dragState = rememberDraggableState { delta ->
liveDrag = (liveDrag - delta / travelPx).coerceIn(0f, 1f)
}
val dragModifier = Modifier.draggable(
orientation = Orientation.Vertical,
state = dragState,
onDragStarted = {
dragging = true
bodyVisible = true
fullyExpanded = false
liveDrag = anim.value
},
onDragStopped = { velocity ->
val tgt = when {
velocity < -flingThreshPx -> true
velocity > flingThreshPx -> false
else -> liveDrag > 0.5f
}
scope.launch {
anim.snapTo(liveDrag)
dragging = false
if (tgt == expandedTarget) {
// Target unchanged → the expandedTarget effect won't
// re-fire, so settle here.
if (tgt) {
anim.animateTo(1f, ExpandSpec); fullyExpanded = true
} else {
anim.animateTo(0f, CollapseSpec); bodyVisible = false
}
} else {
// Hand the new target to the activity; its
// expandedTarget effect runs the settle.
onTargetChange(tgt)
}
}
},
)
// ---- Layer 1: expanded detail body (below the player) ----
if (bodyVisible) {
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
val fr = if (dragging) liveDrag else anim.value
val s = lerp(collapsedScale, 1f, fr)
val playerBottomNow = lerp(collapsedTopPx, statusTopPx, fr) + expandedHpx * s
translationY = playerBottomNow - playerBottomExpandedPx
alpha = fr
}
.background(MaterialTheme.colorScheme.background),
) {
VideoDetailBody(
streamUrl = cur.streamUrl,
topPadding = with(density) { playerBottomExpandedPx.toDp() },
onOpenChannel = onOpenChannel,
onOpenVideo = onOpenVideo,
onCollapse = { onTargetChange(false) },
)
}
}
// ---- Layer 2: collapsed bar (the minibar chrome) ----
if (!fullyExpanded) {
val np by NowPlaying.current.collectAsStateWithLifecycle()
val barTitle = np?.title?.takeIf { it.isNotBlank() } ?: cur.title
val barUploader = np?.uploader.orEmpty()
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(with(density) { (barHpx + navBottomPx).toDp() })
.graphicsLayer {
alpha = (1f - (if (dragging) liveDrag else anim.value)).coerceIn(0f, 1f)
}
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = !fullyExpanded) { onTargetChange(true) }
.then(dragModifier),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.height(with(density) { barHpx.toDp() })
// Leave room on the left for the floating player.
.padding(start = 116.dp, end = 8.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
barTitle,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (barUploader.isNotBlank()) {
Text(
barUploader,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
BarIconButton(
icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
desc = if (isPlaying) "Pause" else "Play",
) {
controller?.let { if (it.isPlaying) it.pause() else it.play() }
}
BarIconButton(icon = Icons.Filled.Close, desc = "Close") {
controller?.let {
it.stop()
it.clearMediaItems()
}
NowPlaying.clear()
OpenVideo.clear()
// Reset the activity flag so the next back press
// isn't swallowed trying to collapse a gone player.
onTargetChange(false)
}
}
}
}
// ---- Layer 3: the morphing player surface (always mounted) ----
Box(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.aspectRatio(16f / 9f)
.graphicsLayer {
val fr = if (dragging) liveDrag else anim.value
val s = lerp(collapsedScale, 1f, fr)
transformOrigin = TransformOrigin(0f, 0f)
scaleX = s
scaleY = s
translationX = lerp(collapsedXpx, 0f, fr)
translationY = lerp(collapsedTopPx, statusTopPx, fr)
}
.clip(RoundedCornerShape(0.dp))
.background(Color.Black)
// Tap-to-expand only while not fully expanded; when expanded
// a disabled clickable passes taps to the PlayerView controls.
.clickable(enabled = !fullyExpanded) { onTargetChange(true) }
.then(dragModifier),
) {
InlinePlayerSurface(
streamUrl = cur.streamUrl,
title = cur.title,
controlsEnabled = fullyExpanded,
onFullscreen = { onFullscreen(cur.streamUrl, cur.title) },
)
}
}
}
@Composable
private fun BarIconButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
desc: String,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(imageVector = icon, contentDescription = desc, modifier = Modifier.size(22.dp))
}
}
/**
* The single TextureView-backed PlayerView, plus the resolveplay wiring
* that used to live in VideoDetailScreen's InlinePlayer. Renders a
* thumbnail + spinner until the shared controller has actually swapped to
* this video, then the live surface. [controlsEnabled] gates the Media3
* controller overlay (off while collapsed so taps fall through to the
* expand gesture). Honors the Auto-start-playback setting: when off, a
* Play overlay waits for a tap before priming the stream.
*/
@OptIn(UnstableApi::class)
@Composable
private fun InlinePlayerSurface(
streamUrl: String,
title: String,
controlsEnabled: Boolean,
onFullscreen: () -> Unit,
) {
val controller = LocalStrawController.current
val vm: VideoDetailViewModel = viewModel()
val state by vm.ui.collectAsStateWithLifecycle()
val detail = state.detail
val resolved = state.resolved
val autoStart by Settings.get().autoStartPlayback.collectAsState()
var started by remember(streamUrl) {
mutableStateOf(NowPlaying.current.value?.streamUrl == streamUrl || autoStart)
}
var retryVersion by remember(streamUrl) { mutableIntStateOf(0) }
LaunchedEffect(controller, resolved, streamUrl, retryVersion, started) {
if (!started) return@LaunchedEffect
val c = controller ?: return@LaunchedEffect
val r = resolved ?: return@LaunchedEffect
if (NowPlaying.current.value?.streamUrl == streamUrl) return@LaunchedEffect
c.setPlayingFrom(
streamUrl = streamUrl,
title = title,
uploader = detail?.uploader.orEmpty(),
thumbnail = detail?.thumbnail,
resolved = r,
uploaderUrl = detail?.uploaderUrl,
)
}
var playbackError by remember(streamUrl) { mutableStateOf<String?>(null) }
DisposableEffect(controller) {
val c = controller ?: return@DisposableEffect onDispose {}
val listener = object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
val raw = error.message ?: "(no message)"
playbackError = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}"
NowPlaying.clear()
}
}
c.addListener(listener)
onDispose { c.removeListener(listener) }
}
val nowPlaying by NowPlaying.current.collectAsStateWithLifecycle()
val controllerOnThisVideo = nowPlaying?.streamUrl == streamUrl
val thumbnail = detail?.thumbnail
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when {
!started -> {
// Auto-start off: thumbnail + Play overlay, tap to begin.
if (!thumbnail.isNullOrBlank()) {
AsyncImage(
model = thumbnail,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
}
Box(
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(OverlayDimColor)
.clickable { started = true },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = Color.White,
modifier = Modifier.size(40.dp),
)
}
}
controller == null || state.loading -> CircularProgressIndicator(color = Color.White)
state.error != null -> Text(
"playback error: ${state.error}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
playbackError != null -> Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"playback error: $playbackError",
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = {
playbackError = null
retryVersion += 1
}) { Text("Retry") }
}
resolved?.isPlayable != true -> Text(
"no playable stream",
color = Color.White,
modifier = Modifier.padding(16.dp),
)
!controllerOnThisVideo -> {
if (!thumbnail.isNullOrBlank()) {
AsyncImage(
model = thumbnail,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
}
CircularProgressIndicator(color = Color.White)
}
else -> {
AndroidView(
factory = { ctx ->
// Inflate from XML for a TEXTURE_VIEW surface — a
// SurfaceView won't follow the graphicsLayer scale,
// which is exactly the morph here.
(LayoutInflater.from(ctx)
.inflate(R.layout.inline_player_view, null) as PlayerView)
.apply {
player = controller
useController = controlsEnabled
keepScreenOn = true
}
},
update = {
it.player = controller
it.useController = controlsEnabled
},
onRelease = { it.player = null },
modifier = Modifier.fillMaxSize(),
)
if (controlsEnabled) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.background(OverlayChromeColor)
.clickable(onClick = onFullscreen),
contentAlignment = Alignment.Center,
) {
Text("", color = Color.White)
}
}
}
}
}
}

View file

@ -1,202 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The minibar: a thin persistent strip pinned to the bottom of every
* non-Player screen whenever a video is loaded into the MediaController.
* Tap to expand back to fullscreen. The × clears playback and dismisses.
*
* The actual player + audio lives in PlaybackService this composable
* is purely UI on top of the MediaController. Pause/play toggles the
* controller, which is the same player feeding the fullscreen surface
* and the inline detail player. There is only ever one player.
*/
package com.sulkta.straw.feature.player
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import coil3.compose.AsyncImage
@OptIn(UnstableApi::class)
@Composable
fun MinibarOverlay(
onExpand: () -> Unit,
modifier: Modifier = Modifier,
) {
val controller = LocalStrawController.current
val item by NowPlaying.current.collectAsStateWithLifecycle()
if (controller == null || item == null) return
val cur = item ?: return
// Reflect the controller's play state in the play/pause icon. Listening
// is the only reliable way; isPlaying snapshots stale between events.
var isPlaying by remember { mutableStateOf(controller.isPlaying) }
val ctx = androidx.compose.ui.platform.LocalContext.current
DisposableEffect(controller) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
}
// audit MED-Q11: if Background-button took the user
// to Home and the foreground audio fails, the only Player
// surface still listening is this minibar.
// + Q11: also stop the controller so a
// future tap doesn't seek into the dead state, AND clear
// NowPlaying so the minibar hides itself. (PlayerScreen
// and VideoDetailScreen's listeners also clear NowPlaying
// now, so this is the fallback when neither is alive.)
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
android.widget.Toast.makeText(
ctx,
"playback error: ${error.errorCodeName}",
android.widget.Toast.LENGTH_LONG,
).show()
runCatching {
controller.stop()
controller.clearMediaItems()
}
NowPlaying.clear()
}
}
controller.addListener(listener)
isPlaying = controller.isPlaying
onDispose { controller.removeListener(listener) }
}
// Swipe up on the bar to expand back to the full player (vc=74). Was
// tap-only before — Cobb couldn't swipe the minibar back up to return
// to the video. Tap still works; this just adds the upward-drag path.
// Accumulates the vertical drag and expands when released past a small
// upward threshold (delta is negative going up).
val density = androidx.compose.ui.platform.LocalDensity.current
val expandThresholdPx = with(density) { 32.dp.toPx() }
var dragUp by remember { mutableFloatStateOf(0f) }
val expandDragState = rememberDraggableState { delta -> dragUp += delta }
// navigationBarsPadding shifts the whole minibar up by the system
// nav-bar height so the bar sits ABOVE the gesture pill / 3-button
// nav, not behind them. enableEdgeToEdge in StrawActivity means
// anything aligned BottomCenter lands under those buttons otherwise.
Column(modifier = modifier.fillMaxWidth().navigationBarsPadding()) {
HorizontalDivider()
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.clickable(onClick = onExpand)
.draggable(
orientation = Orientation.Vertical,
state = expandDragState,
onDragStarted = { dragUp = 0f },
onDragStopped = {
if (dragUp < -expandThresholdPx) onExpand()
dragUp = 0f
},
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 8.dp),
) {
AsyncImage(
model = cur.thumbnail,
contentDescription = null,
modifier = Modifier
.size(width = 80.dp, height = 48.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color.Black),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
cur.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
cur.uploader,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
MinibarIconButton(
icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
desc = if (isPlaying) "Pause" else "Play",
) {
if (controller.isPlaying) controller.pause() else controller.play()
}
MinibarIconButton(icon = Icons.Filled.Close, desc = "Stop") {
controller.stop()
controller.clearMediaItems()
NowPlaying.clear()
}
}
}
}
}
}
@Composable
private fun MinibarIconButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
desc: String,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(22.dp))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(imageVector = icon, contentDescription = desc, modifier = Modifier.size(22.dp))
}
}

View file

@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* "Currently OPEN video" the one the expandable player is showing,
* whether it's expanded to the full detail page or collapsed to the
* bottom minibar. This is distinct from [NowPlaying]:
*
* - OpenVideo = what the user opened / is looking at (the expandable
* container exists iff this is non-null).
* - NowPlaying = what the shared MediaController is actually streaming.
*
* In steady state they're the same video, but OpenVideo is set the
* instant the user taps a video (before the stream resolves), and it
* carries only the lightweight bits the collapsed bar needs before
* strawcore has finished resolving. The full detail/metadata comes from
* the shared VideoDetailViewModel once it loads.
*
* Process-wide singleton for the same reason NowPlaying is one: the
* expandable player lives at the activity layout level, above any
* specific Screen.* composable, and must outlive screen transitions.
*/
package com.sulkta.straw.feature.player
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class OpenVideoItem(
val streamUrl: String,
val title: String,
)
object OpenVideo {
private val _current = MutableStateFlow<OpenVideoItem?>(null)
val current: StateFlow<OpenVideoItem?> = _current.asStateFlow()
/** Open a video into the expandable player (or swap to a new one). */
fun open(item: OpenVideoItem) {
_current.value = item
}
/** Dismiss the expandable player entirely (the × on the minibar). */
fun clear() {
_current.value = null
}
}