vc=22: inline→fullscreen position handoff + local playlists

This commit is contained in:
Kayos 2026-05-25 15:57:56 +00:00
parent 599d299b2a
commit e7d45aa6b4
9 changed files with 575 additions and 8 deletions

View file

@ -16,6 +16,15 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// Sulkta fork — Straw
//
// vc=22 / 0.1.0-AH — V-2 player polish + local playlists:
// * Inline → fullscreen now hands off seek position. Tap Play (or the
// ⛶ pill on the inline player) while the inline is mid-track and
// the fullscreen Player picks up at the same point. Same handoff
// pattern as fullscreen → background from vc=21.
// * Local playlists: drawer entry "Playlists", "Save" button on
// VideoDetail. SharedPreferences-backed, no queue/autoplay yet
// (tap an entry to open VideoDetail as normal).
//
// vc=21 / 0.1.0-AG — player hand-off polish:
// * 🎧 background-audio button now captures the current position and
// resumes the foreground service from there instead of restarting.
@ -32,6 +41,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 = 21
const val STRAW_VERSION_NAME = "0.1.0-AG"
const val STRAW_VERSION_CODE = 22
const val STRAW_VERSION_NAME = "0.1.0-AH"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -16,9 +16,15 @@ sealed interface Screen {
data object Home : Screen
data object Search : Screen
data object Settings : Screen
data object Playlists : Screen
data class VideoDetail(val streamUrl: String, val title: String) : Screen
data class Player(val streamUrl: String, val title: String) : Screen
data class Player(
val streamUrl: String,
val title: String,
val startPositionMs: Long = 0L,
) : Screen
data class Channel(val channelUrl: String, val name: String) : Screen
data class PlaylistView(val playlistId: String, val name: String) : Screen
}
class Navigator(initial: Screen) {

View file

@ -23,6 +23,8 @@ import com.sulkta.straw.feature.channel.ChannelScreen
import com.sulkta.straw.feature.detail.VideoDetailScreen
import com.sulkta.straw.feature.player.PlayerLeaveHandler
import com.sulkta.straw.feature.player.PlayerScreen
import com.sulkta.straw.feature.playlist.PlaylistViewScreen
import com.sulkta.straw.feature.playlist.PlaylistsScreen
import com.sulkta.straw.feature.search.SearchScreen
import com.sulkta.straw.feature.settings.SettingsScreen
@ -67,6 +69,7 @@ class StrawActivity : ComponentActivity() {
is Screen.Home -> StrawHome(
onOpenSearch = { nav.push(Screen.Search) },
onOpenSettings = { nav.push(Screen.Settings) },
onOpenPlaylists = { nav.push(Screen.Playlists) },
onOpenVideo = { url, title ->
nav.push(Screen.VideoDetail(url, title))
},
@ -83,8 +86,8 @@ class StrawActivity : ComponentActivity() {
is Screen.VideoDetail -> VideoDetailScreen(
streamUrl = s.streamUrl,
initialTitle = s.title,
onPlay = {
nav.push(Screen.Player(s.streamUrl, s.title))
onPlay = { startPositionMs ->
nav.push(Screen.Player(s.streamUrl, s.title, startPositionMs))
},
onOpenChannel = { url, name ->
nav.push(Screen.Channel(url, name))
@ -103,6 +106,19 @@ class StrawActivity : ComponentActivity() {
is Screen.Player -> PlayerScreen(
streamUrl = s.streamUrl,
title = s.title,
startPositionMs = s.startPositionMs,
)
is Screen.Playlists -> PlaylistsScreen(
onOpenPlaylist = { id, name ->
nav.push(Screen.PlaylistView(id, name))
},
)
is Screen.PlaylistView -> PlaylistViewScreen(
playlistId = s.playlistId,
initialName = s.name,
onOpenVideo = { url, title ->
nav.push(Screen.VideoDetail(url, title))
},
)
}
}

View file

@ -7,6 +7,7 @@ package com.sulkta.straw
import android.app.Application
import com.sulkta.straw.data.History
import com.sulkta.straw.data.Playlists
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions
@ -21,5 +22,6 @@ class StrawApp : Application() {
History.init(this)
Settings.init(this)
Subscriptions.init(this)
Playlists.init(this)
}
}

View file

@ -78,6 +78,7 @@ private enum class HomeView { Subs, History }
fun StrawHome(
onOpenSearch: () -> Unit,
onOpenSettings: () -> Unit,
onOpenPlaylists: () -> Unit,
onOpenVideo: (url: String, title: String) -> Unit,
onOpenChannel: (channelUrl: String, name: String) -> Unit,
feedVm: SubscriptionFeedViewModel = viewModel(),
@ -125,6 +126,16 @@ fun StrawHome(
},
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text("Playlists") },
icon = { Text("📃") },
selected = false,
onClick = {
scope.launch { drawerState.close() }
onOpenPlaylists()
},
modifier = Modifier.padding(horizontal = 12.dp),
)
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
NavigationDrawerItem(
label = { Text("Settings") },

View file

@ -0,0 +1,126 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* SharedPreferences-lite local playlists. User creates a playlist
* ("study music", "boss fight rage"), saves videos to it from
* VideoDetailScreen, and replays them later from the drawer. Same
* persistence pattern as SubscriptionsStore JSON blob in
* SharedPreferences, atomic updates via updateAndGet so concurrent
* "save to playlist" taps don't lose entries.
*
* No queue-autoplay yet tapping a video in a playlist navigates to
* VideoDetail like normal. Queue handoff would be its own task.
*/
package com.sulkta.straw.data
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.UUID
@Serializable
data class PlaylistItem(
val streamUrl: String,
val title: String,
val thumbnail: String? = null,
val uploader: String = "",
val addedAt: Long = 0L,
)
@Serializable
data class Playlist(
val id: String,
val name: String,
val createdAt: Long,
val items: List<PlaylistItem> = emptyList(),
)
private const val PREFS = "straw_playlists"
private const val KEY = "playlists_v1"
class PlaylistsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private val _playlists = MutableStateFlow(load())
val playlists: StateFlow<List<Playlist>> = _playlists.asStateFlow()
fun create(name: String): Playlist {
val pl = Playlist(
id = UUID.randomUUID().toString(),
name = name.trim().ifBlank { "Untitled" },
createdAt = System.currentTimeMillis(),
)
val next = _playlists.updateAndGet { it + pl }
persist(next)
return pl
}
fun delete(id: String) {
val next = _playlists.updateAndGet { cur -> cur.filterNot { it.id == id } }
persist(next)
}
fun rename(id: String, newName: String) {
val trimmed = newName.trim().ifBlank { return }
val next = _playlists.updateAndGet { cur ->
cur.map { if (it.id == id) it.copy(name = trimmed) else it }
}
persist(next)
}
fun addItem(playlistId: String, item: PlaylistItem) {
val stamped = item.copy(addedAt = System.currentTimeMillis())
val next = _playlists.updateAndGet { cur ->
cur.map { pl ->
if (pl.id != playlistId) pl
else if (pl.items.any { it.streamUrl == stamped.streamUrl }) pl
else pl.copy(items = pl.items + stamped)
}
}
persist(next)
}
fun removeItem(playlistId: String, streamUrl: String) {
val next = _playlists.updateAndGet { cur ->
cur.map { pl ->
if (pl.id != playlistId) pl
else pl.copy(items = pl.items.filterNot { it.streamUrl == streamUrl })
}
}
persist(next)
}
fun get(id: String): Playlist? = _playlists.value.firstOrNull { it.id == id }
private fun persist(list: List<Playlist>) {
sp.edit().putString(KEY, json.encodeToString(list)).apply()
}
private fun load(): List<Playlist> = runCatching {
val s = sp.getString(KEY, null) ?: return emptyList()
json.decodeFromString<List<Playlist>>(s)
}.getOrDefault(emptyList())
}
object Playlists {
@Volatile private var instance: PlaylistsStore? = null
fun init(context: Context) {
if (instance == null) {
synchronized(this) {
if (instance == null) instance = PlaylistsStore(context.applicationContext)
}
}
}
fun get(): PlaylistsStore = instance
?: error("PlaylistsStore not initialized — call Playlists.init(context)")
}

View file

@ -27,18 +27,25 @@ import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
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 android.content.Intent
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.material3.AlertDialog
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.sulkta.straw.data.PlaylistItem
import com.sulkta.straw.data.Playlists
import kotlinx.coroutines.delay
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import com.sulkta.straw.feature.download.DownloadKind
@ -78,7 +85,7 @@ import com.sulkta.straw.util.stripHtml
fun VideoDetailScreen(
streamUrl: String,
initialTitle: String,
onPlay: () -> Unit,
onPlay: (startPositionMs: Long) -> Unit,
onOpenChannel: (channelUrl: String, name: String) -> Unit,
onOpenVideo: (url: String, title: String) -> Unit,
vm: VideoDetailViewModel = viewModel(),
@ -86,9 +93,14 @@ fun VideoDetailScreen(
val state by vm.ui.collectAsStateWithLifecycle()
val context = LocalContext.current
var showDownloadDialog by remember { mutableStateOf(false) }
var showSaveToPlaylistDialog by remember { mutableStateOf(false) }
// Inline-play state. Resets when the user navigates to a different
// video (keyed on streamUrl).
var inlinePlaying by remember(streamUrl) { mutableStateOf(false) }
// V-2: inline player's current position, polled into here so the
// outer can pass it through when the user taps Play / ⛶. Resets to 0
// when the inline player isn't active.
var inlinePositionMs by remember(streamUrl) { mutableLongStateOf(0L) }
LaunchedEffect(streamUrl) { vm.load(streamUrl) }
Column(
@ -117,7 +129,8 @@ fun VideoDetailScreen(
if (inlinePlaying) {
InlinePlayer(
streamUrl = streamUrl,
onFullscreen = onPlay,
onFullscreen = { onPlay(inlinePositionMs) },
onPositionChanged = { inlinePositionMs = it },
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
@ -209,7 +222,7 @@ fun VideoDetailScreen(
Spacer(modifier = Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onPlay) { Text("Play") }
Button(onClick = { onPlay(inlinePositionMs) }) { Text("Play") }
OutlinedButton(onClick = {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
@ -221,6 +234,9 @@ fun VideoDetailScreen(
OutlinedButton(onClick = { showDownloadDialog = true }) {
Text("Download")
}
OutlinedButton(onClick = { showSaveToPlaylistDialog = true }) {
Text("Save")
}
}
Spacer(modifier = Modifier.height(20.dp))
@ -265,6 +281,18 @@ fun VideoDetailScreen(
}
}
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(
@ -369,6 +397,90 @@ 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 = {
androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Close") }
},
)
}
/**
* Inline player embedded in the 16:9 thumbnail box on VideoDetailScreen.
* Uses its own ExoPlayer + PlayerView (with the built-in controller for
@ -382,6 +494,7 @@ private fun RelatedRow(
private fun InlinePlayer(
streamUrl: String,
onFullscreen: () -> Unit,
onPositionChanged: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@ -455,6 +568,15 @@ private fun InlinePlayer(
}
}
// V-2: report inline position to the parent so the Play / ⛶ button
// can pick up where playback was when the user goes fullscreen.
LaunchedEffect(exoPlayer) {
while (true) {
onPositionChanged(exoPlayer.currentPosition.coerceAtLeast(0L))
delay(500)
}
}
Box(modifier = modifier, contentAlignment = Alignment.Center) {
when {
state.loading -> CircularProgressIndicator(color = Color.White)

View file

@ -80,6 +80,7 @@ import kotlinx.coroutines.delay
fun PlayerScreen(
streamUrl: String,
title: String,
startPositionMs: Long = 0L,
vm: PlayerViewModel = viewModel(),
) {
val context = LocalContext.current
@ -216,6 +217,13 @@ fun PlayerScreen(
if (source != null) {
exoPlayer.setMediaSource(source)
// V-2: when we navigate here from an inline player that was
// already playing, pick up at the same position instead of
// restarting. seekTo() before prepare() is allowed; the seek
// is queued and applied once the player is ready.
if (startPositionMs > 0) {
exoPlayer.seekTo(startPositionMs)
}
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}

View file

@ -0,0 +1,267 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Two-screen unit for local playlists:
* PlaylistsScreen root list of all user playlists (drawer entry)
* PlaylistViewScreen items inside one playlist, tap to open
*/
package com.sulkta.straw.feature.playlist
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.sulkta.straw.data.Playlists
@Composable
fun PlaylistsScreen(
onOpenPlaylist: (id: String, name: String) -> Unit,
) {
val store = Playlists.get()
val playlists by store.playlists.collectAsState()
var showCreate by remember { mutableStateOf(false) }
var newName by remember { mutableStateOf("") }
var pendingDelete by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(horizontal = 20.dp, vertical = 12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Playlists",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Button(onClick = { showCreate = true; newName = "" }) { Text("+ New") }
}
Spacer(modifier = Modifier.height(8.dp))
Text(
"${playlists.size} playlist${if (playlists.size == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
if (playlists.isEmpty()) {
Text(
"No playlists yet. Tap + New, or use the Save button on a video.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LazyColumn {
items(playlists, key = { it.id }) { pl ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenPlaylist(pl.id, pl.name) }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.width(56.dp)
.height(56.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Text("📃")
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
pl.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
"${pl.items.size} video${if (pl.items.size == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(onClick = { pendingDelete = pl.id }) {
Text("Delete")
}
}
HorizontalDivider()
}
}
}
if (showCreate) {
AlertDialog(
onDismissRequest = { showCreate = false },
title = { Text("New playlist") },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
Button(onClick = {
store.create(newName)
showCreate = false
}) { Text("Create") }
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = { showCreate = false }) {
Text("Cancel")
}
},
)
}
pendingDelete?.let { id ->
val name = store.get(id)?.name ?: "this playlist"
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Delete \"$name\"?") },
text = { Text("This removes the playlist and its saved video references. Doesn't delete any downloaded files.") },
confirmButton = {
Button(onClick = {
store.delete(id)
pendingDelete = null
}) { Text("Delete") }
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = { pendingDelete = null }) {
Text("Cancel")
}
},
)
}
}
}
@Composable
fun PlaylistViewScreen(
playlistId: String,
initialName: String,
onOpenVideo: (url: String, title: String) -> Unit,
) {
val store = Playlists.get()
val playlists by store.playlists.collectAsState()
val playlist = playlists.firstOrNull { it.id == playlistId }
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(horizontal = 20.dp, vertical = 12.dp),
) {
Text(
playlist?.name ?: initialName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
if (playlist == null) {
Text(
"Playlist not found.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
Text(
"${playlist.items.size} video${if (playlist.items.size == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
if (playlist.items.isEmpty()) {
Text(
"Empty. Tap Save on a video to add it.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LazyColumn {
items(playlist.items, key = { it.streamUrl }) { item ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenVideo(item.streamUrl, item.title) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.Top,
) {
AsyncImage(
model = item.thumbnail,
contentDescription = null,
modifier = Modifier
.width(140.dp)
.height(80.dp)
.clip(RoundedCornerShape(6.dp)),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
item.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
item.uploader,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
androidx.compose.material3.TextButton(onClick = {
store.removeItem(playlist.id, item.streamUrl)
}) { Text("×") }
}
HorizontalDivider()
}
}
}
}
}