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 Cobb asked for tonight.
This commit is contained in:
parent
c515fabf71
commit
c3583457fb
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
|
||||
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
|
||||
// NewPipeExtractor in the runtime path.
|
||||
const val STRAW_VERSION_CODE = 45
|
||||
const val STRAW_VERSION_NAME = "0.1.0-BE"
|
||||
const val STRAW_VERSION_CODE = 46
|
||||
const val STRAW_VERSION_NAME = "0.1.0-BF"
|
||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@
|
|||
|
||||
package com.sulkta.straw
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.WatchHistoryItem
|
||||
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.OverlayDimColor
|
||||
import com.sulkta.straw.util.formatDuration
|
||||
|
|
@ -234,6 +238,10 @@ fun StrawHome(
|
|||
@Composable
|
||||
private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) {
|
||||
val watches by History.get().watches.collectAsState()
|
||||
var actionTarget by remember { mutableStateOf<VideoActionTarget?>(null) }
|
||||
actionTarget?.let { t ->
|
||||
VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
|
||||
}
|
||||
|
||||
Column {
|
||||
Text(
|
||||
|
|
@ -252,7 +260,18 @@ private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) {
|
|||
} else {
|
||||
LazyColumn {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -269,6 +288,10 @@ private fun SubsPane(
|
|||
val subs by Subscriptions.get().subs.collectAsState()
|
||||
val feed by feedVm.ui.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() }
|
||||
|
||||
// Filter + pagination state. hideWatched is sticky for the session
|
||||
|
|
@ -415,7 +438,18 @@ private fun SubsPane(
|
|||
}
|
||||
LazyColumn(state = listState) {
|
||||
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()
|
||||
}
|
||||
if (hasMore) {
|
||||
|
|
@ -456,12 +490,17 @@ private val VIDEO_ID_RE = Regex("(?:v=|/)([A-Za-z0-9_-]{11})(?:[?&#].*)?$")
|
|||
private fun extractVideoId(url: String): String =
|
||||
VIDEO_ID_RE.find(url)?.groupValues?.getOrNull(1).orEmpty()
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
|
||||
private fun FeedRow(
|
||||
item: StreamItem,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
|
|
@ -586,12 +625,17 @@ private fun SubChip(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun RecentRow(item: WatchHistoryItem, onClick: () -> Unit) {
|
||||
private fun RecentRow(
|
||||
item: WatchHistoryItem,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
|
||||
package com.sulkta.straw.feature.channel
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.unit.dp
|
||||
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.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import com.sulkta.straw.data.ChannelRef
|
||||
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.util.formatCount
|
||||
import com.sulkta.straw.util.formatDuration
|
||||
|
|
@ -61,6 +68,10 @@ fun ChannelScreen(
|
|||
LaunchedEffect(channelUrl) { vm.load(channelUrl) }
|
||||
val subs by Subscriptions.get().subs.collectAsState()
|
||||
val subscribed = subs.any { it.url == channelUrl }
|
||||
var actionTarget by remember { mutableStateOf<VideoActionTarget?>(null) }
|
||||
actionTarget?.let { t ->
|
||||
VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
|
||||
}
|
||||
|
||||
when {
|
||||
state.loading -> Box(
|
||||
|
|
@ -130,19 +141,35 @@ fun ChannelScreen(
|
|||
HorizontalDivider()
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ChannelVideoRow(item: StreamItem, onClick: () -> Unit) {
|
||||
private fun ChannelVideoRow(
|
||||
item: StreamItem,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
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.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
|
|
@ -92,7 +94,7 @@ import coil3.compose.AsyncImage
|
|||
import com.sulkta.straw.OverlayChromeColor
|
||||
import com.sulkta.straw.OverlayDimColor
|
||||
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.Downloader
|
||||
import com.sulkta.straw.feature.player.LocalStrawController
|
||||
|
|
@ -123,6 +125,13 @@ fun VideoDetailScreen(
|
|||
val activity = context as? Activity
|
||||
var showDownloadDialog by remember { mutableStateOf(false) }
|
||||
var showSaveToPlaylistDialog by remember { mutableStateOf(false) }
|
||||
var actionTarget by remember { mutableStateOf<com.sulkta.straw.feature.playlist.VideoActionTarget?>(null) }
|
||||
actionTarget?.let { t ->
|
||||
com.sulkta.straw.feature.playlist.VideoActionsSheet(
|
||||
target = t,
|
||||
onDismiss = { actionTarget = null },
|
||||
)
|
||||
}
|
||||
// Inline-play state resets when navigating to a different video.
|
||||
// BUT: if the shared MediaController is already playing this exact
|
||||
// stream — most commonly because the user popped back from
|
||||
|
|
@ -536,7 +545,18 @@ fun VideoDetailScreen(
|
|||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -551,7 +571,18 @@ fun VideoDetailScreen(
|
|||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -629,15 +660,17 @@ fun VideoDetailScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun RelatedRow(
|
||||
item: StreamItem,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(vertical = 8.dp),
|
||||
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
|
||||
* a PlayerView bound to the shared LocalStrawController — the same
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* 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
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -44,6 +46,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
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.formatViews
|
||||
|
||||
|
|
@ -55,6 +61,10 @@ fun SearchScreen(
|
|||
) {
|
||||
val state by vm.ui.collectAsStateWithLifecycle()
|
||||
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)) {
|
||||
OutlinedTextField(
|
||||
|
|
@ -153,6 +163,14 @@ fun SearchScreen(
|
|||
ResultRow(
|
||||
item = item,
|
||||
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) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
|
@ -163,16 +181,18 @@ fun SearchScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ResultRow(
|
||||
item: StreamItem,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onChannelClick: (url: String) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue