diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 5277bb4cc..64e89c979 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt index 1b57d3471..430dab189 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt @@ -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 diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 4f9e1616f..a00e7f5a5 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -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, ) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailBody.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailBody.kt new file mode 100644 index 000000000..768751020 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailBody.kt @@ -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(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) + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt deleted file mode 100644 index 6c0332ea1..000000000 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ /dev/null @@ -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(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(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) - } - } - } - } -} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ExpandablePlayer.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ExpandablePlayer.kt new file mode 100644 index 000000000..17b2e1cbe --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ExpandablePlayer.kt @@ -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(durationMillis = 300, easing = FastOutSlowInEasing) +private val CollapseSpec = tween(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(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) + } + } + } + } + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt deleted file mode 100644 index e50e576c6..000000000 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/MinibarOverlay.kt +++ /dev/null @@ -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)) - } -} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/OpenVideo.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/OpenVideo.kt new file mode 100644 index 000000000..01bc081a3 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/OpenVideo.kt @@ -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(null) + val current: StateFlow = _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 + } +}