Expandable player morph: one container, video page ⇄ minibar (vc=75)
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:
parent
e2723adc71
commit
7b28d94189
8 changed files with 1294 additions and 1211 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 resolve→play 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue