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.
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) {
|
||||
|
||||
|
Before Width: | Height: | Size: 867 KiB After Width: | Height: | Size: 868 KiB |
|
Before Width: | Height: | Size: 826 KiB After Width: | Height: | Size: 827 KiB |
|
Before Width: | Height: | Size: 782 KiB After Width: | Height: | Size: 783 KiB |
|
Before Width: | Height: | Size: 887 KiB After Width: | Height: | Size: 888 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 644 KiB After Width: | Height: | Size: 646 KiB |
|
Before Width: | Height: | Size: 641 KiB After Width: | Height: | Size: 642 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |