mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(android): long-press menus on library tiles, and Undo
- a long press on a title opens a sheet: Play or Resume, Open, Download all, and Mark watched or unwatched for the whole title - a long press on a Continue watching card offers Resume, Open, and Remove from Continue watching, which forgets the position as the desktop does - marking a title and leaving Continue watching both say what they did in a snackbar with Undo, which puts every changed watch state back - the "next up" episode is one shared function, used by the title page and the menu alike
This commit is contained in:
1 parent
65ecc099b6
commit
0bc7cba428
6 files changed
+242
-22
No files matched your search
@@ -33,6 +33,7 @@ import uniffi.pstr_android.MetadataProvider
|
||||
import uniffi.pstr_android.MetadataSettingsRecord
|
||||
import uniffi.pstr_android.StorageUsageRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.WatchStateRecord
|
||||
|
||||
data class AppUiState(
|
||||
val loading: Boolean = true,
|
||||
@@ -49,8 +50,16 @@ data class AppUiState(
|
||||
),
|
||||
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
|
||||
val message: String? = null,
|
||||
/** What Undo on [message] puts back, when the message offers one. */
|
||||
val undo: List<WatchSnapshot>? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One episode's watch state as it was before a change the viewer can undo.
|
||||
* Null [state] means it had never been played.
|
||||
*/
|
||||
data class WatchSnapshot(val shareId: String, val linkId: String, val state: WatchStateRecord?)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
class AppViewModel(context: Context, private val workManager: WorkManager) : ViewModel() {
|
||||
private val appContext = context.applicationContext
|
||||
@@ -185,7 +194,88 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissMessage() = mutableState.update { it.copy(message = null) }
|
||||
fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) }
|
||||
|
||||
/**
|
||||
* Every episode of a title watched or unwatched, with an Undo: a whole
|
||||
* title's positions are what this throws away, and a mis-tap on a tile's
|
||||
* menu should not cost them.
|
||||
*/
|
||||
fun setTitleWatched(title: TitleRecord, watched: Boolean) {
|
||||
val episodes = title.seasons.flatMap { it.episodes }
|
||||
changeWatch(
|
||||
episodes,
|
||||
"${title.canonicalName ?: title.name} marked ${if (watched) "watched" else "unwatched"}",
|
||||
) { engine, episode, before ->
|
||||
val duration = before?.durationSecs
|
||||
engine.saveWatchState(
|
||||
episode.shareId,
|
||||
episode.linkId,
|
||||
if (watched) duration ?: 0.0 else 0.0,
|
||||
duration,
|
||||
watched,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Off Continue watching by forgetting where the episode stopped — what the
|
||||
* desktop does — with an Undo that puts the position back.
|
||||
*/
|
||||
fun forgetPosition(title: TitleRecord, episode: EpisodeRecord) {
|
||||
changeWatch(
|
||||
listOf(episode),
|
||||
"${title.canonicalName ?: title.name} removed from Continue watching",
|
||||
) { engine, target, before ->
|
||||
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Put back what the last undoable change replaced. */
|
||||
fun undo() {
|
||||
val snapshots = mutableState.value.undo ?: return
|
||||
mutableState.update { it.copy(message = null, undo = null) }
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
snapshots.forEach { snapshot ->
|
||||
val state = snapshot.state
|
||||
engine.saveWatchState(
|
||||
snapshot.shareId,
|
||||
snapshot.linkId,
|
||||
state?.positionSecs ?: 0.0,
|
||||
state?.durationSecs,
|
||||
state?.watched ?: false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeWatch(
|
||||
episodes: List<EpisodeRecord>,
|
||||
message: String,
|
||||
change: (uniffi.pstr_android.AndroidEngine, EpisodeRecord, WatchStateRecord?) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
episodes.map { episode ->
|
||||
val before = engine.watchState(episode.shareId, episode.linkId)
|
||||
change(engine, episode, before)
|
||||
WatchSnapshot(episode.shareId, episode.linkId, before)
|
||||
}
|
||||
}
|
||||
}.onSuccess { snapshots ->
|
||||
mutableState.update { it.copy(message = message, undo = snapshots) }
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
fun reportError(error: Throwable) {
|
||||
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
@Composable
|
||||
internal fun EmptyState(title: String, body: String) {
|
||||
@@ -19,6 +20,15 @@ internal fun EmptyState(title: String, body: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Play on a title lands: the most recently played part-watched episode,
|
||||
* else the first unwatched one, else the first — the desktop's `next_up`.
|
||||
*/
|
||||
internal fun nextUpIndex(playlist: List<EpisodeRecord>): Int =
|
||||
playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
|
||||
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
||||
?: 0
|
||||
|
||||
internal fun formatBytes(bytes: ULong): String {
|
||||
val value = bytes.toDouble()
|
||||
return when {
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.RemoveCircleOutline
|
||||
import androidx.compose.material.icons.outlined.CheckCircle
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -65,8 +81,14 @@ internal fun LibraryScreen(
|
||||
onResume: (TitleRecord, Int) -> Unit,
|
||||
onTitle: (TitleRecord) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onSetTitleWatched: (TitleRecord, Boolean) -> Unit,
|
||||
onForgetPosition: (TitleRecord, EpisodeRecord) -> Unit,
|
||||
padding: PaddingValues,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
// What a long press opened a menu for: a title, or a Continue watching card.
|
||||
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
|
||||
var menuResume by remember { mutableStateOf<Resumable?>(null) }
|
||||
// Most recently played first, one episode per show: a shelf that lists four
|
||||
// episodes of the same series is a shelf with room for nothing else.
|
||||
val resumable = remember(state.titles) {
|
||||
@@ -117,10 +139,89 @@ internal fun LibraryScreen(
|
||||
"Your library is empty",
|
||||
"Add a Proton Drive public link under Shares, then refresh.",
|
||||
)
|
||||
else -> LibraryGrid(state, resumable, onResume, onTitle)
|
||||
else -> LibraryGrid(
|
||||
state,
|
||||
resumable,
|
||||
onResume,
|
||||
onTitle,
|
||||
onTitleMenu = { menuFor = it },
|
||||
onResumeMenu = { menuResume = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
menuFor?.let { title ->
|
||||
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
|
||||
TileMenu(title.canonicalName ?: title.name, title.caption(), onDismiss = { menuFor = null }) {
|
||||
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
|
||||
onResume(title, nextUpIndex(playlist))
|
||||
}
|
||||
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
|
||||
MenuRow(Icons.Default.Download, "Download all") { DownloadCoordinator.enqueue(context, playlist) }
|
||||
MenuRow(
|
||||
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
|
||||
if (allWatched) "Mark unwatched" else "Mark watched",
|
||||
) { onSetTitleWatched(title, !allWatched) }
|
||||
}
|
||||
}
|
||||
menuResume?.let { entry ->
|
||||
TileMenu(
|
||||
entry.title.canonicalName ?: entry.title.name,
|
||||
entry.episode.label,
|
||||
onDismiss = { menuResume = null },
|
||||
) {
|
||||
MenuRow(Icons.Default.PlayArrow, "Resume") { onResume(entry.title, entry.index) }
|
||||
MenuRow(Icons.Default.Info, "Open") { onTitle(entry.title) }
|
||||
MenuRow(Icons.Default.RemoveCircleOutline, "Remove from Continue watching") {
|
||||
onForgetPosition(entry.title, entry.episode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A long press's menu, from the bottom of the screen where the thumb already
|
||||
* is. Each row closes it before doing its thing.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TileMenu(
|
||||
heading: String,
|
||||
caption: String,
|
||||
onDismiss: () -> Unit,
|
||||
rows: @Composable MenuScope.() -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(Modifier.padding(bottom = 24.dp)) {
|
||||
Text(
|
||||
heading,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
)
|
||||
Text(
|
||||
caption,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
|
||||
)
|
||||
MenuScope(onDismiss).rows()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class MenuScope(val dismiss: () -> Unit)
|
||||
|
||||
@Composable
|
||||
private fun MenuScope.MenuRow(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||
ListItem(
|
||||
headlineContent = { Text(label) },
|
||||
leadingContent = { Icon(icon, contentDescription = null) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable { dismiss(); onClick() }.padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -129,6 +230,8 @@ private fun LibraryGrid(
|
||||
resumable: List<Resumable>,
|
||||
onResume: (TitleRecord, Int) -> Unit,
|
||||
onTitle: (TitleRecord) -> Unit,
|
||||
onTitleMenu: (TitleRecord) -> Unit,
|
||||
onResumeMenu: (Resumable) -> Unit,
|
||||
) {
|
||||
// Posters, three across on a phone. Cropped backdrops at full width fitted
|
||||
// two titles to a screen, which is a library read one row at a time.
|
||||
@@ -152,7 +255,11 @@ private fun LibraryGrid(
|
||||
contentPadding = PaddingValues(top = 10.dp),
|
||||
) {
|
||||
items(resumable, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
|
||||
ContinueCard(entry) { onResume(entry.title, entry.index) }
|
||||
ContinueCard(
|
||||
entry,
|
||||
onClick = { onResume(entry.title, entry.index) },
|
||||
onLongClick = { onResumeMenu(entry) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,14 +268,21 @@ private fun LibraryGrid(
|
||||
Text("Library", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
items(state.titles, key = { it.key }) { title -> PosterTile(title) { onTitle(title) } }
|
||||
items(state.titles, key = { it.key }) { title ->
|
||||
PosterTile(title, onClick = { onTitle(title) }, onLongClick = { onTitleMenu(title) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A part-watched episode: its still, where it was left, and what is left of it. */
|
||||
@Composable
|
||||
private fun ContinueCard(entry: Resumable, onClick: () -> Unit) {
|
||||
Column(Modifier.width(220.dp).clip(MaterialTheme.shapes.medium).clickable(onClick = onClick)) {
|
||||
private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () -> Unit) {
|
||||
Column(
|
||||
Modifier
|
||||
.width(220.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
|
||||
RemoteArtwork(
|
||||
entry.episode.stillUrl ?: entry.title.backdropUrl,
|
||||
@@ -202,8 +316,12 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit) {
|
||||
|
||||
/** One title: its poster, and its name and size under it rather than in a card. */
|
||||
@Composable
|
||||
private fun PosterTile(title: TitleRecord, onClick: () -> Unit) {
|
||||
Column(Modifier.clip(MaterialTheme.shapes.medium).clickable(onClick = onClick)) {
|
||||
private fun PosterTile(title: TitleRecord, onClick: () -> Unit, onLongClick: () -> Unit) {
|
||||
Column(
|
||||
Modifier
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
) {
|
||||
RemoteArtwork(
|
||||
title.posterUrl ?: title.backdropUrl,
|
||||
title.canonicalName ?: title.name,
|
||||
|
||||
@@ -22,7 +22,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -132,8 +134,13 @@ fun ProtonStreamApp(
|
||||
}
|
||||
LaunchedEffect(state.message) {
|
||||
state.message?.let {
|
||||
snackbars.showSnackbar(it)
|
||||
model.dismissMessage()
|
||||
val undoable = state.undo != null
|
||||
val result = snackbars.showSnackbar(
|
||||
it,
|
||||
actionLabel = if (undoable) "Undo" else null,
|
||||
duration = if (undoable) SnackbarDuration.Long else SnackbarDuration.Short,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) model.undo() else model.dismissMessage()
|
||||
}
|
||||
}
|
||||
// Read out here because `navigationSuiteItems` is not a composable scope.
|
||||
@@ -202,6 +209,8 @@ fun ProtonStreamApp(
|
||||
},
|
||||
{ selectedTitleKey = it.key },
|
||||
model::refresh,
|
||||
model::setTitleWatched,
|
||||
model::forgetPosition,
|
||||
body,
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -98,14 +98,7 @@ internal fun TitleScreen(
|
||||
// Display order across seasons: what previous/next and autoplay walk, and
|
||||
// the same order the desktop client uses.
|
||||
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
||||
// Where a press on Play lands. Part-watched wins over unwatched, most
|
||||
// recently played first, which is the order the desktop client resumes in.
|
||||
val nextUp = remember(playlist) {
|
||||
val resumable = playlist.withIndex().filter { it.value.resumeAt != null }
|
||||
resumable.maxByOrNull { it.value.lastPlayed }?.index
|
||||
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
||||
?: 0
|
||||
}
|
||||
val nextUp = remember(playlist) { nextUpIndex(playlist) }
|
||||
val upNext = playlist.getOrNull(nextUp)
|
||||
// One season on screen at a time, opened on the one Play would land in: a
|
||||
// forty-episode show as stacked sections is one long scroll to the part
|
||||
|
||||
@@ -59,13 +59,13 @@ class ScreenshotTest {
|
||||
|
||||
@Test
|
||||
fun `the library on a phone`() = capture("library-phone") {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, PaddingValues())
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||
fun `the library on a tablet`() = capture("library-tablet") {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, PaddingValues())
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -74,13 +74,13 @@ class ScreenshotTest {
|
||||
// reads it from, so the scale is set there.
|
||||
val density = LocalDensity.current
|
||||
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, PaddingValues())
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty library`() = capture("library-empty") {
|
||||
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, PaddingValues())
|
||||
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user