diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 15a86913b..3106cdb6b 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt index 02396749c..3e5c7edf6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt @@ -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) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 57746b38c..340dcf846 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -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)) + }, ) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 7444f3ab8..ca36407c9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -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) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 31e1f2452..2e72d6ab6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -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") }, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt new file mode 100644 index 000000000..cf72bd597 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt @@ -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 = 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> = _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) { + sp.edit().putString(KEY, json.encodeToString(list)).apply() + } + + private fun load(): List = runCatching { + val s = sp.getString(KEY, null) ?: return emptyList() + json.decodeFromString>(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)") +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index ecb92d66e..01ea63ef1 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -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) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 8a7e6c1ad..748c61379 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -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 } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt new file mode 100644 index 000000000..44c1ad36f --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt @@ -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(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() + } + } + } + } +}