vc=22: inline→fullscreen position handoff + local playlists
This commit is contained in:
parent
599d299b2a
commit
e7d45aa6b4
9 changed files with 575 additions and 8 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
|
|
|
|||
126
strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt
Normal file
126
strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt
Normal 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)")
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue