vc=46: long-press video actions — save to playlist + share
Build offline playlists from any list, not just one-at-a-time from
VideoDetail.
* Long-press any video row → ModalBottomSheet with title/uploader
header + actions: Save to playlist, Share.
* Save reuses the existing playlist dialog (extracted out of
VideoDetailScreen into feature/playlist/VideoActions.kt and
promoted to public).
* Share fires a system ACTION_SEND with the YT URL.
* Wired across all 5 video-row sites: Search results, Subs feed,
History, Channel videos, Related/More-from-channel on
VideoDetail.
Deferred to next ship:
* "Play next" / "Add to queue" — needs Media3 queue substrate +
per-item streamInfo resolution path. Separate ticket; non-blocking
for the offline-playlist build flow a tester asked for tonight.
This commit is contained in:
parent
a7a9a2adf3
commit
6ba0bdeed7
6 changed files with 396 additions and 100 deletions
|
|
@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
||||||
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
||||||
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
|
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
|
||||||
// NewPipeExtractor in the runtime path.
|
// NewPipeExtractor in the runtime path.
|
||||||
const val STRAW_VERSION_CODE = 45
|
const val STRAW_VERSION_CODE = 46
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-BE"
|
const val STRAW_VERSION_NAME = "0.1.0-BF"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@
|
||||||
|
|
||||||
package com.sulkta.straw
|
package com.sulkta.straw
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -78,6 +80,8 @@ import com.sulkta.straw.data.History
|
||||||
import com.sulkta.straw.data.Subscriptions
|
import com.sulkta.straw.data.Subscriptions
|
||||||
import com.sulkta.straw.data.WatchHistoryItem
|
import com.sulkta.straw.data.WatchHistoryItem
|
||||||
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
|
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
|
||||||
|
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.feature.search.StreamItem
|
||||||
import com.sulkta.straw.OverlayDimColor
|
import com.sulkta.straw.OverlayDimColor
|
||||||
import com.sulkta.straw.util.formatDuration
|
import com.sulkta.straw.util.formatDuration
|
||||||
|
|
@ -234,6 +238,10 @@ fun StrawHome(
|
||||||
@Composable
|
@Composable
|
||||||
private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) {
|
private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) {
|
||||||
val watches by History.get().watches.collectAsState()
|
val watches by History.get().watches.collectAsState()
|
||||||
|
var actionTarget by remember { mutableStateOf<VideoActionTarget?>(null) }
|
||||||
|
actionTarget?.let { t ->
|
||||||
|
VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
|
||||||
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -252,7 +260,18 @@ private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) {
|
||||||
} else {
|
} else {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(watches) { w ->
|
items(watches) { w ->
|
||||||
RecentRow(w) { onOpenVideo(w.url, w.title) }
|
RecentRow(
|
||||||
|
item = w,
|
||||||
|
onClick = { onOpenVideo(w.url, w.title) },
|
||||||
|
onLongClick = {
|
||||||
|
actionTarget = VideoActionTarget(
|
||||||
|
streamUrl = w.url,
|
||||||
|
title = w.title,
|
||||||
|
uploader = w.uploader,
|
||||||
|
thumbnail = w.thumbnail,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -269,6 +288,10 @@ private fun SubsPane(
|
||||||
val subs by Subscriptions.get().subs.collectAsState()
|
val subs by Subscriptions.get().subs.collectAsState()
|
||||||
val feed by feedVm.ui.collectAsState()
|
val feed by feedVm.ui.collectAsState()
|
||||||
val watches by History.get().watches.collectAsState()
|
val watches by History.get().watches.collectAsState()
|
||||||
|
var actionTarget by remember { mutableStateOf<VideoActionTarget?>(null) }
|
||||||
|
actionTarget?.let { t ->
|
||||||
|
VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
|
||||||
|
}
|
||||||
LaunchedEffect(subs) { feedVm.refreshIfStale() }
|
LaunchedEffect(subs) { feedVm.refreshIfStale() }
|
||||||
|
|
||||||
// Filter + pagination state. hideWatched is sticky for the session
|
// Filter + pagination state. hideWatched is sticky for the session
|
||||||
|
|
@ -415,7 +438,18 @@ private fun SubsPane(
|
||||||
}
|
}
|
||||||
LazyColumn(state = listState) {
|
LazyColumn(state = listState) {
|
||||||
items(displayed) { item ->
|
items(displayed) { item ->
|
||||||
FeedRow(item) { onOpenVideo(item.url, item.title) }
|
FeedRow(
|
||||||
|
item = item,
|
||||||
|
onClick = { onOpenVideo(item.url, item.title) },
|
||||||
|
onLongClick = {
|
||||||
|
actionTarget = VideoActionTarget(
|
||||||
|
streamUrl = item.url,
|
||||||
|
title = item.title,
|
||||||
|
uploader = item.uploader,
|
||||||
|
thumbnail = item.thumbnail,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
if (hasMore) {
|
if (hasMore) {
|
||||||
|
|
@ -456,12 +490,17 @@ private val VIDEO_ID_RE = Regex("(?:v=|/)([A-Za-z0-9_-]{11})(?:[?&#].*)?$")
|
||||||
private fun extractVideoId(url: String): String =
|
private fun extractVideoId(url: String): String =
|
||||||
VIDEO_ID_RE.find(url)?.groupValues?.getOrNull(1).orEmpty()
|
VIDEO_ID_RE.find(url)?.groupValues?.getOrNull(1).orEmpty()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
|
private fun FeedRow(
|
||||||
|
item: StreamItem,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick)
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top,
|
||||||
) {
|
) {
|
||||||
|
|
@ -586,12 +625,17 @@ private fun SubChip(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun RecentRow(item: WatchHistoryItem, onClick: () -> Unit) {
|
private fun RecentRow(
|
||||||
|
item: WatchHistoryItem,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick)
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
|
|
||||||
package com.sulkta.straw.feature.channel
|
package com.sulkta.straw.feature.channel
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -41,11 +43,16 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.sulkta.straw.data.ChannelRef
|
import com.sulkta.straw.data.ChannelRef
|
||||||
import com.sulkta.straw.data.Subscriptions
|
import com.sulkta.straw.data.Subscriptions
|
||||||
|
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.feature.search.StreamItem
|
||||||
import com.sulkta.straw.util.formatCount
|
import com.sulkta.straw.util.formatCount
|
||||||
import com.sulkta.straw.util.formatDuration
|
import com.sulkta.straw.util.formatDuration
|
||||||
|
|
@ -61,6 +68,10 @@ fun ChannelScreen(
|
||||||
LaunchedEffect(channelUrl) { vm.load(channelUrl) }
|
LaunchedEffect(channelUrl) { vm.load(channelUrl) }
|
||||||
val subs by Subscriptions.get().subs.collectAsState()
|
val subs by Subscriptions.get().subs.collectAsState()
|
||||||
val subscribed = subs.any { it.url == channelUrl }
|
val subscribed = subs.any { it.url == channelUrl }
|
||||||
|
var actionTarget by remember { mutableStateOf<VideoActionTarget?>(null) }
|
||||||
|
actionTarget?.let { t ->
|
||||||
|
VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
|
||||||
|
}
|
||||||
|
|
||||||
when {
|
when {
|
||||||
state.loading -> Box(
|
state.loading -> Box(
|
||||||
|
|
@ -130,19 +141,35 @@ fun ChannelScreen(
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
items(state.videos) { item ->
|
items(state.videos) { item ->
|
||||||
ChannelVideoRow(item) { onOpenVideo(item.url, item.title) }
|
ChannelVideoRow(
|
||||||
|
item = item,
|
||||||
|
onClick = { onOpenVideo(item.url, item.title) },
|
||||||
|
onLongClick = {
|
||||||
|
actionTarget = VideoActionTarget(
|
||||||
|
streamUrl = item.url,
|
||||||
|
title = item.title,
|
||||||
|
uploader = item.uploader,
|
||||||
|
thumbnail = item.thumbnail,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChannelVideoRow(item: StreamItem, onClick: () -> Unit) {
|
private fun ChannelVideoRow(
|
||||||
|
item: StreamItem,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick)
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@ import androidx.compose.animation.core.FastOutLinearInEasing
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.gestures.Orientation
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
import androidx.compose.foundation.gestures.draggable
|
import androidx.compose.foundation.gestures.draggable
|
||||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||||
|
|
@ -92,7 +94,7 @@ import coil3.compose.AsyncImage
|
||||||
import com.sulkta.straw.OverlayChromeColor
|
import com.sulkta.straw.OverlayChromeColor
|
||||||
import com.sulkta.straw.OverlayDimColor
|
import com.sulkta.straw.OverlayDimColor
|
||||||
import com.sulkta.straw.data.PlaylistItem
|
import com.sulkta.straw.data.PlaylistItem
|
||||||
import com.sulkta.straw.data.Playlists
|
import com.sulkta.straw.feature.playlist.SaveToPlaylistDialog
|
||||||
import com.sulkta.straw.feature.download.DownloadKind
|
import com.sulkta.straw.feature.download.DownloadKind
|
||||||
import com.sulkta.straw.feature.download.Downloader
|
import com.sulkta.straw.feature.download.Downloader
|
||||||
import com.sulkta.straw.feature.player.LocalStrawController
|
import com.sulkta.straw.feature.player.LocalStrawController
|
||||||
|
|
@ -123,6 +125,13 @@ fun VideoDetailScreen(
|
||||||
val activity = context as? Activity
|
val activity = context as? Activity
|
||||||
var showDownloadDialog by remember { mutableStateOf(false) }
|
var showDownloadDialog by remember { mutableStateOf(false) }
|
||||||
var showSaveToPlaylistDialog by remember { mutableStateOf(false) }
|
var showSaveToPlaylistDialog by remember { mutableStateOf(false) }
|
||||||
|
var actionTarget by remember { mutableStateOf<com.sulkta.straw.feature.playlist.VideoActionTarget?>(null) }
|
||||||
|
actionTarget?.let { t ->
|
||||||
|
com.sulkta.straw.feature.playlist.VideoActionsSheet(
|
||||||
|
target = t,
|
||||||
|
onDismiss = { actionTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
// Inline-play state resets when navigating to a different video.
|
// Inline-play state resets when navigating to a different video.
|
||||||
// BUT: if the shared MediaController is already playing this exact
|
// BUT: if the shared MediaController is already playing this exact
|
||||||
// stream — most commonly because the user popped back from
|
// stream — most commonly because the user popped back from
|
||||||
|
|
@ -536,7 +545,18 @@ fun VideoDetailScreen(
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
d.related.take(20).forEach { rel ->
|
d.related.take(20).forEach { rel ->
|
||||||
RelatedRow(rel) { onOpenVideo(rel.url, rel.title) }
|
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()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -551,7 +571,18 @@ fun VideoDetailScreen(
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
d.moreFromChannel.take(20).forEach { item ->
|
d.moreFromChannel.take(20).forEach { item ->
|
||||||
RelatedRow(item) { onOpenVideo(item.url, item.title) }
|
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()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -629,15 +660,17 @@ fun VideoDetailScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun RelatedRow(
|
private fun RelatedRow(
|
||||||
item: StreamItem,
|
item: StreamItem,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick)
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top,
|
||||||
) {
|
) {
|
||||||
|
|
@ -676,90 +709,6 @@ private fun RelatedRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SaveToPlaylistDialog(
|
|
||||||
item: PlaylistItem,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val store = Playlists.get()
|
|
||||||
val playlists by store.playlists.collectAsState()
|
|
||||||
var creatingNew by remember { mutableStateOf(false) }
|
|
||||||
var newName by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Save to playlist") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
if (playlists.isEmpty() && !creatingNew) {
|
|
||||||
Text(
|
|
||||||
"No playlists yet. Create one to save this video.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
playlists.forEach { pl ->
|
|
||||||
val already = pl.items.any { it.streamUrl == item.streamUrl }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(enabled = !already) {
|
|
||||||
store.addItem(pl.id, item)
|
|
||||||
Toast.makeText(context, "saved to ${pl.name}", Toast.LENGTH_SHORT).show()
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
.padding(vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(if (already) "✓" else "○", modifier = Modifier.width(28.dp))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(pl.name, style = MaterialTheme.typography.bodyLarge)
|
|
||||||
Text(
|
|
||||||
"${pl.items.size} video${if (pl.items.size == 1) "" else "s"}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
if (creatingNew) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = newName,
|
|
||||||
onValueChange = { newName = it },
|
|
||||||
label = { Text("New playlist name") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(onClick = {
|
|
||||||
val pl = store.create(newName)
|
|
||||||
store.addItem(pl.id, item)
|
|
||||||
Toast.makeText(context, "created ${pl.name} + saved", Toast.LENGTH_SHORT).show()
|
|
||||||
onDismiss()
|
|
||||||
}) { Text("Create + save") }
|
|
||||||
OutlinedButton(onClick = { creatingNew = false; newName = "" }) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
OutlinedButton(onClick = { creatingNew = true }) {
|
|
||||||
Text("+ New playlist")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text("Close") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline player surface inside VideoDetail's 16:9 thumbnail box. Renders
|
* Inline player surface inside VideoDetail's 16:9 thumbnail box. Renders
|
||||||
* a PlayerView bound to the shared LocalStrawController — the same
|
* a PlayerView bound to the shared LocalStrawController — the same
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Sulkta
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Shared long-press actions surface for video rows. The menu shows
|
||||||
|
* "Save to playlist" + "Share" (and Add-to-Queue later when the queue
|
||||||
|
* substrate lands). Every video row in the app — Search results,
|
||||||
|
* Subs feed, Channel videos, History, Related — calls
|
||||||
|
* `showVideoActions(...)` from a `combinedClickable.onLongClick`.
|
||||||
|
*
|
||||||
|
* Pure-Compose surface — no ViewModel needed; PlaylistsStore is a
|
||||||
|
* process-wide singleton and the share Intent is a fire-and-forget
|
||||||
|
* Android system action.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.sulkta.straw.feature.playlist
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PlaylistAdd
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.sulkta.straw.data.PlaylistItem
|
||||||
|
import com.sulkta.straw.data.Playlists
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal video descriptor for the actions sheet. Avoids dragging
|
||||||
|
* the full search.StreamItem (which has extractor fields the
|
||||||
|
* actions don't need) so the same surface can be invoked from
|
||||||
|
* history rows where we only have a WatchHistoryItem.
|
||||||
|
*/
|
||||||
|
data class VideoActionTarget(
|
||||||
|
val streamUrl: String,
|
||||||
|
val title: String,
|
||||||
|
val uploader: String,
|
||||||
|
val thumbnail: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun VideoActionsSheet(
|
||||||
|
target: VideoActionTarget,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val sheetState = rememberModalBottomSheetState()
|
||||||
|
var showSaveDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showSaveDialog) {
|
||||||
|
SaveToPlaylistDialog(
|
||||||
|
item = PlaylistItem(
|
||||||
|
streamUrl = target.streamUrl,
|
||||||
|
title = target.title,
|
||||||
|
thumbnail = target.thumbnail,
|
||||||
|
uploader = target.uploader,
|
||||||
|
),
|
||||||
|
onDismiss = {
|
||||||
|
showSaveDialog = false
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
|
||||||
|
// Title row — truncated to one line, gives context for the
|
||||||
|
// actions below.
|
||||||
|
Text(
|
||||||
|
text = target.title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 2,
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
if (target.uploader.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = target.uploader,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 0.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
ActionRow(
|
||||||
|
icon = Icons.Filled.PlaylistAdd,
|
||||||
|
label = "Save to playlist",
|
||||||
|
onClick = { showSaveDialog = true },
|
||||||
|
)
|
||||||
|
ActionRow(
|
||||||
|
icon = Icons.Filled.Share,
|
||||||
|
label = "Share",
|
||||||
|
onClick = {
|
||||||
|
val send = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/plain"
|
||||||
|
putExtra(Intent.EXTRA_TEXT, target.streamUrl)
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, target.title)
|
||||||
|
}
|
||||||
|
context.startActivity(
|
||||||
|
Intent.createChooser(send, "Share video").addFlags(
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_TASK,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActionRow(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
label: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Text(text = label, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared "Save to playlist" dialog — was previously inline in
|
||||||
|
* VideoDetailScreen; promoted to its own file so the long-press
|
||||||
|
* menu on any row can reuse it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SaveToPlaylistDialog(
|
||||||
|
item: PlaylistItem,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val store = Playlists.get()
|
||||||
|
val playlists by store.playlists.collectAsState()
|
||||||
|
var creatingNew by remember { mutableStateOf(false) }
|
||||||
|
var newName by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Save to playlist") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
if (playlists.isEmpty() && !creatingNew) {
|
||||||
|
Text(
|
||||||
|
"No playlists yet. Create one to save this video.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
playlists.forEach { pl ->
|
||||||
|
val already = pl.items.any { it.streamUrl == item.streamUrl }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = !already) {
|
||||||
|
store.addItem(pl.id, item)
|
||||||
|
Toast.makeText(context, "saved to ${pl.name}", Toast.LENGTH_SHORT).show()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(if (already) "✓" else "○", modifier = Modifier.width(28.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(pl.name, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(
|
||||||
|
"${pl.items.size} video${if (pl.items.size == 1) "" else "s"}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
if (creatingNew) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newName,
|
||||||
|
onValueChange = { newName = it },
|
||||||
|
label = { Text("New playlist name") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = {
|
||||||
|
val pl = store.create(newName)
|
||||||
|
store.addItem(pl.id, item)
|
||||||
|
Toast.makeText(context, "created ${pl.name} + saved", Toast.LENGTH_SHORT).show()
|
||||||
|
onDismiss()
|
||||||
|
}) { Text("Create + save") }
|
||||||
|
OutlinedButton(onClick = { creatingNew = false; newName = "" }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
OutlinedButton(onClick = { creatingNew = true }) {
|
||||||
|
Text("+ New playlist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Close") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
|
|
||||||
package com.sulkta.straw.feature.search
|
package com.sulkta.straw.feature.search
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -44,6 +46,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.sulkta.straw.data.History
|
import com.sulkta.straw.data.History
|
||||||
|
import com.sulkta.straw.feature.playlist.VideoActionTarget
|
||||||
|
import com.sulkta.straw.feature.playlist.VideoActionsSheet
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import com.sulkta.straw.util.formatDuration
|
import com.sulkta.straw.util.formatDuration
|
||||||
import com.sulkta.straw.util.formatViews
|
import com.sulkta.straw.util.formatViews
|
||||||
|
|
||||||
|
|
@ -55,6 +61,10 @@ fun SearchScreen(
|
||||||
) {
|
) {
|
||||||
val state by vm.ui.collectAsStateWithLifecycle()
|
val state by vm.ui.collectAsStateWithLifecycle()
|
||||||
val recentSearches by History.get().searches.collectAsState()
|
val recentSearches by History.get().searches.collectAsState()
|
||||||
|
var actionTarget by remember { mutableStateOf<VideoActionTarget?>(null) }
|
||||||
|
actionTarget?.let { target ->
|
||||||
|
VideoActionsSheet(target = target, onDismiss = { actionTarget = null })
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize().statusBarsPadding().padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxSize().statusBarsPadding().padding(16.dp)) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
|
|
@ -153,6 +163,14 @@ fun SearchScreen(
|
||||||
ResultRow(
|
ResultRow(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = { onOpenVideo(item.url, item.title) },
|
onClick = { onOpenVideo(item.url, item.title) },
|
||||||
|
onLongClick = {
|
||||||
|
actionTarget = VideoActionTarget(
|
||||||
|
streamUrl = item.url,
|
||||||
|
title = item.title,
|
||||||
|
uploader = item.uploader,
|
||||||
|
thumbnail = item.thumbnail,
|
||||||
|
)
|
||||||
|
},
|
||||||
onChannelClick = { url -> onOpenChannel(url, item.uploader) },
|
onChannelClick = { url -> onOpenChannel(url, item.uploader) },
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
@ -163,16 +181,18 @@ fun SearchScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ResultRow(
|
private fun ResultRow(
|
||||||
item: StreamItem,
|
item: StreamItem,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
onChannelClick: (url: String) -> Unit,
|
onChannelClick: (url: String) -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick)
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||||
.padding(vertical = 10.dp),
|
.padding(vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue