From 0bc7cba428470616ea81b71cb0b14171a8f7df8c Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 3 Oct 2026 05:14:34 +0200 Subject: [PATCH] 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 --- .../io/narl/protonstream/ui/AppViewModel.kt | 92 +++++++++++- .../java/io/narl/protonstream/ui/Common.kt | 10 ++ .../io/narl/protonstream/ui/LibraryScreen.kt | 132 +++++++++++++++++- .../narl/protonstream/ui/ProtonStreamApp.kt | 13 +- .../io/narl/protonstream/ui/TitleScreen.kt | 9 +- .../io/narl/protonstream/ui/ScreenshotTest.kt | 8 +- 6 files changed, 242 insertions(+), 22 deletions(-) diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index e17da8a..38302e6 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -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? = 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, + 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") } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt index 468e8c2..4bad733 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt @@ -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): 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 { diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index 4023802..03ac6a8 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -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(null) } + var menuResume by remember { mutableStateOf(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, 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, diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index 35d5178..006cef1 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -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 { diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index afeb81a..a777139 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -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 diff --git a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt index a95cda1..8e8675c 100644 --- a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt +++ b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt @@ -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