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:
Kayos 2026-05-26 04:28:14 -07:00
parent c515fabf71
commit c3583457fb
6 changed files with 396 additions and 100 deletions

View file

@ -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"

View file

@ -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,
) {

View file

@ -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,
) {

View file

@ -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

View file

@ -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") }
},
)
}

View file

@ -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,
) {