feat(core,app,android,ios): My List, synced, and New badges with new-episode rows

My List is a list of titles to watch later, kept in my-list.json by
title key (pstr-core::mylist) and synced as one list/<key> register per
title, so an add or a removal on one device reaches every other. A list
file that will not parse is never written over: changing the list is
refused instead. It is a filter, a row on the library, a button on the
banner and the title page, and an entry in each tile's menu.

pstr-core::news reads node_first_seen against each share's first crawl,
so a newly added share does not badge its whole library. A title that
arrived in the last two weeks and was never played is New; a series
being watched counts the unwatched episodes that arrived after it was
last played ("3 new"). They fill the tile badge and the "New episodes"
and "Recently added" rows. shelves::home puts every row in one order
for all three apps, and a shelf tile of an airing show shows a
countdown ("Ep 8 · in 2 d").

The bridge gains TitleRecord.on_list and .badge, set_on_list, the
MyList filter, airing_countdown and SyncRecord.list_changed.
This commit is contained in:
nvrl committed 2026-10-30 22:15:51 +01:00
1 parent cd9e8eea81
commit f66faf6b75
31 files changed
+1142 -70

No files matched your search

@@ -591,7 +591,9 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}
}
if (report.settingsChanged) AppearanceState.reload()
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.sharesRemoved.isNotEmpty()) {
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.listChanged ||
report.sharesRemoved.isNotEmpty()
) {
reloadNow()
}
if (report.sharesAdded.isNotEmpty()) {
@@ -625,7 +627,17 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
* 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) =
/** Put a title on My List, or take it off. */
fun setOnList(title: TitleRecord, on: Boolean) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().setOnList(title.key, on) }
}.onFailure(::reportError)
reload()
}
}
fun setTitleWatched(title: TitleRecord, watched: Boolean) =
markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName)
/**
@@ -18,6 +18,13 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
@@ -323,3 +330,25 @@ internal fun SpecBadge(text: String) {
.padding(horizontal = 5.dp, vertical = 1.dp),
)
}
/**
* My List's button: a plus to put the title on it, a tick once it is, which
* takes it off again. [compact] is the icon alone, for the banner's row, where
* a third labelled button does not fit a phone.
*/
@Composable
internal fun ListButton(onList: Boolean, onSet: (Boolean) -> Unit, compact: Boolean = false) {
val icon = if (onList) Icons.Default.Check else Icons.Default.Add
val label = if (onList) "Remove from My List" else "Add to My List"
if (compact) {
FilledTonalIconButton(onClick = { onSet(!onList) }, shape = MaterialTheme.shapes.small) {
Icon(icon, contentDescription = label)
}
} else {
TonalButton(onClick = { onSet(!onList) }, modifier = Modifier.semantics { contentDescription = label }) {
Icon(icon, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text("My List")
}
}
}
@@ -47,6 +47,7 @@ import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.QuietButton
import java.time.LocalDate
import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
@@ -116,6 +117,7 @@ import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
import uniffi.pstr_android.airingCountdown
/**
* How many shows the continue-watching shelf holds.
@@ -145,6 +147,7 @@ internal fun LibraryFilter.label(): String = when (this) {
LibraryFilter.WATCHING -> "Watching"
LibraryFilter.UNWATCHED -> "Not started"
LibraryFilter.DOWNLOADED -> "Downloaded"
LibraryFilter.MY_LIST -> "My List"
}
/** The offer of posters and synopses, and its two answers. */
@@ -190,6 +193,7 @@ internal fun LibraryScreen(
onAddShare: () -> Unit = {},
onOpenSettings: () -> Unit = {},
onFilter: (LibraryFilter) -> Unit = {},
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
) {
val download = rememberDownload()
val context = LocalContext.current
@@ -293,6 +297,7 @@ internal fun LibraryScreen(
},
featured = if (overBanner) featured else emptyList(),
grid = grid,
onSetOnList = onSetOnList,
)
}
}
@@ -307,6 +312,10 @@ internal fun LibraryScreen(
onResume(title, nextUpIndex(playlist, title.continueIndex))
}
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
MenuRow(
if (title.onList) Icons.Default.Check else Icons.Default.Add,
if (title.onList) "Remove from My List" else "Add to My List",
) { onSetOnList(title, !title.onList) }
MenuRow(Icons.Default.Download, "Download all") { download(playlist, title.displayName) }
MenuRow(
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
@@ -493,6 +502,7 @@ private fun LibraryGrid(
metadataOffer: MetadataOffer? = null,
featured: List<TitleRecord> = emptyList(),
grid: LazyGridState = rememberLazyGridState(),
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
) {
// The arrangement names titles by key; before it has arrived, the titles
// as they came are the grid.
@@ -525,6 +535,7 @@ private fun LibraryGrid(
resumable,
onPlay = onResume,
onOpen = onTitle,
onSetOnList = onSetOnList,
modifier = Modifier.bleed(16.dp),
)
}
@@ -574,6 +585,9 @@ private fun LibraryGrid(
onLongClick = { onTitleMenu(title) },
modifier = Modifier.width(SHELF_TILE_WIDTH),
shared = false,
// On a shelf, an airing show says when its
// next episode is out.
badge = title.badge ?: title.countdown(),
)
}
}
@@ -729,6 +743,7 @@ private fun FeaturedBanner(
resumable: List<Resumable>,
onPlay: (TitleRecord, Int) -> Unit,
onOpen: (TitleRecord) -> Unit,
onSetOnList: (TitleRecord, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val pager = rememberPagerState { titles.size }
@@ -750,6 +765,7 @@ private fun FeaturedBanner(
resumable.firstOrNull { it.title.key == title.key },
onPlay = { onPlay(title, it) },
onOpen = { onOpen(title) },
onSetOnList = { onSetOnList(title, it) },
)
}
if (titles.size > 1) {
@@ -780,7 +796,13 @@ private fun FeaturedBanner(
/** One title on the banner: its art, its veil, and what it is, from the bottom up. */
@Composable
private fun FeaturedPage(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) {
private fun FeaturedPage(
title: TitleRecord,
resume: Resumable?,
onPlay: (Int) -> Unit,
onOpen: () -> Unit,
onSetOnList: (Boolean) -> Unit,
) {
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
var mood by remember(title.key) { mutableStateOf<Color?>(null) }
val background = MaterialTheme.colorScheme.background
@@ -846,6 +868,7 @@ private fun FeaturedPage(title: TitleRecord, resume: Resumable?, onPlay: (Int) -
Spacer(Modifier.width(6.dp))
Text("More info")
}
ListButton(title.onList, onSetOnList, compact = true)
}
val episode = resume?.episode
val progress = episode?.progress?.takeIf { episode.resumeAt != null }
@@ -943,6 +966,7 @@ private fun PosterTile(
modifier: Modifier = Modifier,
folded: Int = 0,
shared: Boolean = true,
badge: String? = title.badge,
) {
Column(
modifier
@@ -976,6 +1000,20 @@ private fun PosterTile(
.semantics { contentDescription = "and $folded more" },
)
}
if (badge != null) {
Text(
badge,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier
.align(Alignment.TopStart)
.padding(6.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.primary)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
Spacer(Modifier.height(6.dp))
Text(
@@ -1017,3 +1055,10 @@ private fun EpisodeRecord.timeLeft(): String? {
val minutes = ((at / fraction - at) / 60).toInt()
return if (minutes < 1) "under a minute left" else "$minutes min left"
}
/** `Ep 8 · in 2 d` for a show whose next episode is still to air. */
internal fun TitleRecord.countdown(now: Long = System.currentTimeMillis() / 1000): String? {
val at = nextAiringAt?.takeIf { it > now } ?: return null
val episode = nextEpisode ?: return null
return airingCountdown(episode, at, now)
}
@@ -234,6 +234,7 @@ fun ProtonStreamApp(
},
onOpenSettings = { destination = Destination.Settings },
onFilter = model::filter,
onSetOnList = model::setOnList,
)
}
val titlePane = @Composable { selected: TitleRecord ->
@@ -255,6 +256,7 @@ fun ProtonStreamApp(
state.titles.firstOrNull { it.key == key }
},
onOpenTitle = { selectedTitleKey = it.key },
onSetOnList = model::setOnList,
)
}
if (maxWidth >= TWO_PANE_WIDTH) {
@@ -50,6 +50,9 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Download
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.RemoveDone
import androidx.compose.material.icons.filled.Pause
@@ -119,6 +122,7 @@ internal fun TitleScreen(
/** Every title of its franchise, in release order, this one included. */
franchise: List<TitleRecord> = emptyList(),
onOpenTitle: (TitleRecord) -> Unit = {},
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
) {
val context = LocalContext.current
val download = rememberDownload()
@@ -311,6 +315,13 @@ internal fun TitleScreen(
// three button styles used to wrap over three lines and bury
// the episodes under them.
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
TitleAction(
if (title.onList) Icons.Default.Check else Icons.Default.Add,
"My List",
Modifier.weight(1f).semantics {
stateDescription = if (title.onList) "On My List" else "Not on My List"
},
) { onSetOnList(title, !title.onList) }
// Only where it would do something different: an unstarted
// show already starts at the beginning.
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 867 KiB

After

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 826 KiB

After

Width:  |  Height:  |  Size: 827 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 KiB

After

Width:  |  Height:  |  Size: 783 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 887 KiB

After

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 KiB

After

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 KiB

After

Width:  |  Height:  |  Size: 642 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB