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.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() reloadNow()
} }
if (report.sharesAdded.isNotEmpty()) { 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 * title's positions are what this throws away, and a mis-tap on a tile's
* menu should not cost them. * 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) 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.RowScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding 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.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -323,3 +330,25 @@ internal fun SpecBadge(text: String) {
.padding(horizontal = 5.dp, vertical = 1.dp), .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 io.narl.protonstream.ui.theme.QuietButton
import java.time.LocalDate import java.time.LocalDate
import androidx.compose.foundation.combinedClickable 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.CheckCircle
import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit 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.SeasonRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType import uniffi.pstr_android.TitleType
import uniffi.pstr_android.airingCountdown
/** /**
* How many shows the continue-watching shelf holds. * How many shows the continue-watching shelf holds.
@@ -145,6 +147,7 @@ internal fun LibraryFilter.label(): String = when (this) {
LibraryFilter.WATCHING -> "Watching" LibraryFilter.WATCHING -> "Watching"
LibraryFilter.UNWATCHED -> "Not started" LibraryFilter.UNWATCHED -> "Not started"
LibraryFilter.DOWNLOADED -> "Downloaded" LibraryFilter.DOWNLOADED -> "Downloaded"
LibraryFilter.MY_LIST -> "My List"
} }
/** The offer of posters and synopses, and its two answers. */ /** The offer of posters and synopses, and its two answers. */
@@ -190,6 +193,7 @@ internal fun LibraryScreen(
onAddShare: () -> Unit = {}, onAddShare: () -> Unit = {},
onOpenSettings: () -> Unit = {}, onOpenSettings: () -> Unit = {},
onFilter: (LibraryFilter) -> Unit = {}, onFilter: (LibraryFilter) -> Unit = {},
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
) { ) {
val download = rememberDownload() val download = rememberDownload()
val context = LocalContext.current val context = LocalContext.current
@@ -293,6 +297,7 @@ internal fun LibraryScreen(
}, },
featured = if (overBanner) featured else emptyList(), featured = if (overBanner) featured else emptyList(),
grid = grid, grid = grid,
onSetOnList = onSetOnList,
) )
} }
} }
@@ -307,6 +312,10 @@ internal fun LibraryScreen(
onResume(title, nextUpIndex(playlist, title.continueIndex)) onResume(title, nextUpIndex(playlist, title.continueIndex))
} }
MenuRow(Icons.Default.Info, "Open") { onTitle(title) } 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(Icons.Default.Download, "Download all") { download(playlist, title.displayName) }
MenuRow( MenuRow(
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle, if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
@@ -493,6 +502,7 @@ private fun LibraryGrid(
metadataOffer: MetadataOffer? = null, metadataOffer: MetadataOffer? = null,
featured: List<TitleRecord> = emptyList(), featured: List<TitleRecord> = emptyList(),
grid: LazyGridState = rememberLazyGridState(), grid: LazyGridState = rememberLazyGridState(),
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
) { ) {
// The arrangement names titles by key; before it has arrived, the titles // The arrangement names titles by key; before it has arrived, the titles
// as they came are the grid. // as they came are the grid.
@@ -525,6 +535,7 @@ private fun LibraryGrid(
resumable, resumable,
onPlay = onResume, onPlay = onResume,
onOpen = onTitle, onOpen = onTitle,
onSetOnList = onSetOnList,
modifier = Modifier.bleed(16.dp), modifier = Modifier.bleed(16.dp),
) )
} }
@@ -574,6 +585,9 @@ private fun LibraryGrid(
onLongClick = { onTitleMenu(title) }, onLongClick = { onTitleMenu(title) },
modifier = Modifier.width(SHELF_TILE_WIDTH), modifier = Modifier.width(SHELF_TILE_WIDTH),
shared = false, 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>, resumable: List<Resumable>,
onPlay: (TitleRecord, Int) -> Unit, onPlay: (TitleRecord, Int) -> Unit,
onOpen: (TitleRecord) -> Unit, onOpen: (TitleRecord) -> Unit,
onSetOnList: (TitleRecord, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val pager = rememberPagerState { titles.size } val pager = rememberPagerState { titles.size }
@@ -750,6 +765,7 @@ private fun FeaturedBanner(
resumable.firstOrNull { it.title.key == title.key }, resumable.firstOrNull { it.title.key == title.key },
onPlay = { onPlay(title, it) }, onPlay = { onPlay(title, it) },
onOpen = { onOpen(title) }, onOpen = { onOpen(title) },
onSetOnList = { onSetOnList(title, it) },
) )
} }
if (titles.size > 1) { 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. */ /** One title on the banner: its art, its veil, and what it is, from the bottom up. */
@Composable @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) } val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
var mood by remember(title.key) { mutableStateOf<Color?>(null) } var mood by remember(title.key) { mutableStateOf<Color?>(null) }
val background = MaterialTheme.colorScheme.background val background = MaterialTheme.colorScheme.background
@@ -846,6 +868,7 @@ private fun FeaturedPage(title: TitleRecord, resume: Resumable?, onPlay: (Int) -
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("More info") Text("More info")
} }
ListButton(title.onList, onSetOnList, compact = true)
} }
val episode = resume?.episode val episode = resume?.episode
val progress = episode?.progress?.takeIf { episode.resumeAt != null } val progress = episode?.progress?.takeIf { episode.resumeAt != null }
@@ -943,6 +966,7 @@ private fun PosterTile(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
folded: Int = 0, folded: Int = 0,
shared: Boolean = true, shared: Boolean = true,
badge: String? = title.badge,
) { ) {
Column( Column(
modifier modifier
@@ -976,6 +1000,20 @@ private fun PosterTile(
.semantics { contentDescription = "and $folded more" }, .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)) Spacer(Modifier.height(6.dp))
Text( Text(
@@ -1017,3 +1055,10 @@ private fun EpisodeRecord.timeLeft(): String? {
val minutes = ((at / fraction - at) / 60).toInt() val minutes = ((at / fraction - at) / 60).toInt()
return if (minutes < 1) "under a minute left" else "$minutes min left" 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 }, onOpenSettings = { destination = Destination.Settings },
onFilter = model::filter, onFilter = model::filter,
onSetOnList = model::setOnList,
) )
} }
val titlePane = @Composable { selected: TitleRecord -> val titlePane = @Composable { selected: TitleRecord ->
@@ -255,6 +256,7 @@ fun ProtonStreamApp(
state.titles.firstOrNull { it.key == key } state.titles.firstOrNull { it.key == key }
}, },
onOpenTitle = { selectedTitleKey = it.key }, onOpenTitle = { selectedTitleKey = it.key },
onSetOnList = model::setOnList,
) )
} }
if (maxWidth >= TWO_PANE_WIDTH) { 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.outlined.CheckCircle
import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Download 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.Edit
import androidx.compose.material.icons.filled.RemoveDone import androidx.compose.material.icons.filled.RemoveDone
import androidx.compose.material.icons.filled.Pause 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. */ /** Every title of its franchise, in release order, this one included. */
franchise: List<TitleRecord> = emptyList(), franchise: List<TitleRecord> = emptyList(),
onOpenTitle: (TitleRecord) -> Unit = {}, onOpenTitle: (TitleRecord) -> Unit = {},
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
) { ) {
val context = LocalContext.current val context = LocalContext.current
val download = rememberDownload() val download = rememberDownload()
@@ -311,6 +315,13 @@ internal fun TitleScreen(
// three button styles used to wrap over three lines and bury // three button styles used to wrap over three lines and bury
// the episodes under them. // the episodes under them.
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) { 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 // Only where it would do something different: an unstarted
// show already starts at the beginning. // show already starts at the beginning.
if (upNext?.resumeAt != null || title.watchedCount > 0uL) { 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

+95 -12
View File
@@ -246,6 +246,9 @@ pub struct SyncRecord {
pub settings_changed: bool, pub settings_changed: bool,
/// Hand-picked matches and per-show track choices taken. /// Hand-picked matches and per-show track choices taken.
pub titles: u32, pub titles: u32,
/// Whether My List changed: reload the library.
#[uniffi(default = false)]
pub list_changed: bool,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
@@ -357,6 +360,7 @@ pub enum TitleNamesChoice {
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum LibraryFilter { pub enum LibraryFilter {
All, All,
MyList,
Series, Series,
Films, Films,
Watching, Watching,
@@ -478,6 +482,13 @@ pub fn picture_mood(pixels: Vec<u32>) -> Option<u32> {
/// The page colour `background` tinted by a picture's [`picture_mood`], for /// The page colour `background` tinted by a picture's [`picture_mood`], for
/// the veils over that picture. All `0xAARRGGBB`; see /// the veils over that picture. All `0xAARRGGBB`; see
/// `pstr_core::appearance::mood_veil`. /// `pstr_core::appearance::mood_veil`.
/// What an airing show's tile says about its next episode, `Ep 8 · in 2 d`;
/// see `pstr_core::news::countdown`.
#[uniffi::export]
pub fn airing_countdown(episode: u32, at: i64, now: i64) -> String {
pstr_core::news::countdown(episode, at, now)
}
#[uniffi::export] #[uniffi::export]
pub fn mood_veil(background: u32, mood: u32, light: bool) -> u32 { pub fn mood_veil(background: u32, mood: u32, light: bool) -> u32 {
let rgb = |argb: u32| { let rgb = |argb: u32| {
@@ -624,6 +635,12 @@ pub struct TitleRecord {
/// shared `TitleDetails::status_label`. /// shared `TitleDetails::status_label`.
#[uniffi(default = None)] #[uniffi(default = None)]
pub status: Option<String>, pub status: Option<String>,
/// On My List.
#[uniffi(default = false)]
pub on_list: bool,
/// `New`, or `3 new` on a show being watched; see `pstr_core::news`.
#[uniffi(default = None)]
pub badge: Option<String>,
} }
/// Playback preferences that outlive one file, one title and one launch. /// Playback preferences that outlive one file, one title and one launch.
@@ -1328,6 +1345,7 @@ impl AndroidEngine {
shares_removed: profile.shares_removed, shares_removed: profile.shares_removed,
settings_changed: profile.settings, settings_changed: profile.settings,
titles: u32::try_from(profile.titles).unwrap_or(u32::MAX), titles: u32::try_from(profile.titles).unwrap_or(u32::MAX),
list_changed: profile.list,
})) }))
} }
Err(error) if error.is_signed_out() => { Err(error) if error.is_signed_out() => {
@@ -1655,11 +1673,15 @@ impl AndroidEngine {
pstr_core::browse::search(&cached.library, &cached.metadata, &query) pstr_core::browse::search(&cached.library, &cached.metadata, &query)
.into_iter() .into_iter()
.filter(|title| { .filter(|title| {
filter.admits(title, |node| { filter.admits(
cached title,
.offline |node| {
.contains(&(node.share_id.clone(), node.link_id.clone())) cached
}) .offline
.contains(&(node.share_id.clone(), node.link_id.clone()))
},
&cached.list,
)
}) })
.collect(); .collect();
let tiles = pstr_core::browse::arrange( let tiles = pstr_core::browse::arrange(
@@ -1675,13 +1697,20 @@ impl AndroidEngine {
// Shelves are the whole library's, and only when it is shown // Shelves are the whole library's, and only when it is shown
// whole. // whole.
let shelves = if query.trim().is_empty() && filter == Filter::All { let shelves = if query.trim().is_empty() && filter == Filter::All {
pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES) pstr_core::shelves::home(
.into_iter() &cached.library.titles,
.map(|shelf| ShelfRecord { &cached.metadata,
label: shelf.label, &cached.list,
keys: shelf.keys, &cached.first_seen,
}) now(),
.collect() SHELVES,
)
.into_iter()
.map(|shelf| ShelfRecord {
label: shelf.label,
keys: shelf.keys,
})
.collect()
} else { } else {
Vec::new() Vec::new()
}; };
@@ -1698,6 +1727,18 @@ impl AndroidEngine {
}) })
} }
/// Put a title on My List or take it off. Refused when the list on disk
/// would not parse, rather than writing an empty one over it.
pub fn set_on_list(&self, key: String, on: bool) -> Result<(), BridgeError> {
let mut list = pstr_core::mylist::load(&self.dirs).map_err(BridgeError::from_display)?;
if on {
list.add(&key, now());
} else {
list.remove(&key);
}
pstr_core::mylist::save(&self.dirs, &list).map_err(BridgeError::from_display)
}
pub fn metadata_settings(&self) -> Result<MetadataSettingsRecord, BridgeError> { pub fn metadata_settings(&self) -> Result<MetadataSettingsRecord, BridgeError> {
let config = pstr_meta::settings::load(&self.dirs).map_err(BridgeError::from_display)?; let config = pstr_meta::settings::load(&self.dirs).map_err(BridgeError::from_display)?;
let ready = !config.provider.needs_api_key() let ready = !config.provider.needs_api_key()
@@ -2480,11 +2521,15 @@ impl AndroidEngine {
let names = pstr_core::appearance::load(&self.dirs) let names = pstr_core::appearance::load(&self.dirs)
.unwrap_or_default() .unwrap_or_default()
.names; .names;
// Read each time like the appearance: it is one small file, and the
// phone's list changes without the catalog being written.
let list = pstr_core::mylist::load(&self.dirs).unwrap_or_default();
let catalog = self.catalog.lock(); let catalog = self.catalog.lock();
let writes = catalog.writes(); let writes = catalog.writes();
if let Some(cached) = self.library_cache.lock().as_ref() if let Some(cached) = self.library_cache.lock().as_ref()
&& cached.writes == writes && cached.writes == writes
&& cached.names == names && cached.names == names
&& cached.list == list
{ {
return Ok(read(cached)); return Ok(read(cached));
} }
@@ -2518,6 +2563,7 @@ impl AndroidEngine {
}) })
.collect(); .collect();
let now = now(); let now = now();
let badges = pstr_core::news::badges(&library.titles, &first_seen, now);
let titles: Vec<TitleRecord> = library let titles: Vec<TitleRecord> = library
.titles .titles
.iter() .iter()
@@ -2531,6 +2577,8 @@ impl AndroidEngine {
record.display_name = record.display_name =
pstr_core::browse::display_name(title, &metadata, names).to_owned(); pstr_core::browse::display_name(title, &metadata, names).to_owned();
record.franchise = franchises.get(&title.key).cloned().unwrap_or_default(); record.franchise = franchises.get(&title.key).cloned().unwrap_or_default();
record.on_list = list.contains(&title.key);
record.badge = badges.get(&title.key).map(|badge| badge.label());
if let Some(details) = metadata if let Some(details) = metadata
.get(&title.key) .get(&title.key)
.and_then(|record| record.metadata.as_ref()) .and_then(|record| record.metadata.as_ref())
@@ -2547,6 +2595,7 @@ impl AndroidEngine {
let cached = CachedLibrary { let cached = CachedLibrary {
writes, writes,
names, names,
list,
library, library,
metadata, metadata,
added, added,
@@ -3425,6 +3474,7 @@ fn normalized_language(language: Option<String>) -> Option<String> {
struct CachedLibrary { struct CachedLibrary {
writes: u64, writes: u64,
names: TitleNames, names: TitleNames,
list: pstr_core::mylist::MyList,
library: Library, library: Library,
metadata: HashMap<String, MetadataRecord>, metadata: HashMap<String, MetadataRecord>,
/// When each title last gained a file. /// When each title last gained a file.
@@ -3535,6 +3585,7 @@ fn filter_choice(filter: LibraryFilter) -> Filter {
LibraryFilter::Watching => Filter::Watching, LibraryFilter::Watching => Filter::Watching,
LibraryFilter::Unwatched => Filter::Unwatched, LibraryFilter::Unwatched => Filter::Unwatched,
LibraryFilter::Downloaded => Filter::Downloaded, LibraryFilter::Downloaded => Filter::Downloaded,
LibraryFilter::MyList => Filter::MyList,
} }
} }
@@ -3693,6 +3744,8 @@ fn title_record(
.map(str::to_owned), .map(str::to_owned),
next_episode: None, next_episode: None,
next_airing_at: None, next_airing_at: None,
on_list: false,
badge: None,
franchise: Vec::new(), franchise: Vec::new(),
logo_url: metadata.and_then(|found| found.logo_url.clone()), logo_url: metadata.and_then(|found| found.logo_url.clone()),
runtime: details.and_then(|details| details.runtime), runtime: details.and_then(|details| details.runtime),
@@ -4345,6 +4398,36 @@ mod tests {
let titles = engine.library(Some("frieren".into())).expect("library"); let titles = engine.library(Some("frieren".into())).expect("library");
assert_eq!(titles[0].display_name, "Sousou no Frieren"); assert_eq!(titles[0].display_name, "Sousou no Frieren");
// My List: the record says so straight away, the filter narrows to
// it, and it is a row above the grid.
engine
.set_on_list("sousou no frieren".into(), true)
.expect("list");
let titles = engine.library(Some("frieren".into())).expect("library");
assert!(titles[0].on_list);
let listed = engine
.arrangement(
None,
super::LibraryFilter::MyList,
super::LibrarySort::Name,
false,
)
.expect("arrange");
assert_eq!(listed.tiles.len(), 1);
let whole = engine
.arrangement(
None,
super::LibraryFilter::All,
super::LibrarySort::Name,
true,
)
.expect("arrange");
assert_eq!(whole.shelves[0].label, "My List");
engine
.set_on_list("sousou no frieren".into(), false)
.expect("list");
assert!(!engine.library(None).expect("library")[0].on_list);
let _ = std::fs::remove_dir_all(root); let _ = std::fs::remove_dir_all(root);
} }
+79 -17
View File
@@ -13,6 +13,7 @@ use pstr_core::appearance::Appearance;
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::library::Library; use pstr_core::library::Library;
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames}; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames};
use pstr_core::mylist::MyList;
use crate::engine::{ use crate::engine::{
AccountStatus, DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures, AccountStatus, DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures,
@@ -293,6 +294,11 @@ pub enum Action {
SetShelf(Filter, Sort), SetShelf(Filter, Sort),
/// Fold each franchise into one tile, or show every title. /// Fold each franchise into one tile, or show every title.
SetGrouped(bool), SetGrouped(bool),
/// Put a title on My List, or take it off.
SetOnList {
key: String,
on: bool,
},
/// Open or close the list of keyboard shortcuts. /// Open or close the list of keyboard shortcuts.
ToggleShortcuts, ToggleShortcuts,
/// One page back: a title to the library, the library to nowhere. /// One page back: a title to the library, the library to nowhere.
@@ -410,6 +416,10 @@ pub struct LibraryView {
pub history: Vec<ui::history::Entry>, pub history: Vec<ui::history::Entry>,
/// The "Continue watching" shelf, most recent first, as indices. /// The "Continue watching" shelf, most recent first, as indices.
pub resumable: Vec<usize>, pub resumable: Vec<usize>,
/// What each tile says about what arrived — `New`, `3 new` — by key.
pub badges: HashMap<String, String>,
/// The keys on My List.
pub listed: std::collections::HashSet<String>,
/// The library or what is known about it changed under the lists. /// The library or what is known about it changed under the lists.
stale: bool, stale: bool,
/// Only watch state changed: the shelves and franchises still stand, /// Only watch state changed: the shelves and franchises still stand,
@@ -445,6 +455,8 @@ impl Default for LibraryView {
shelves: Vec::new(), shelves: Vec::new(),
history: Vec::new(), history: Vec::new(),
resumable: Vec::new(), resumable: Vec::new(),
badges: HashMap::new(),
listed: std::collections::HashSet::new(),
stale: false, stale: false,
progressed: false, progressed: false,
} }
@@ -462,6 +474,10 @@ pub struct ViewInputs<'a> {
pub added: &'a HashMap<String, i64>, pub added: &'a HashMap<String, i64>,
/// The files kept on this computer, for the Downloaded filter. /// The files kept on this computer, for the Downloaded filter.
pub offline: &'a std::collections::HashSet<DownloadKey>, pub offline: &'a std::collections::HashSet<DownloadKey>,
pub list: &'a MyList,
/// When each file first appeared in a crawl, for what is new.
pub first_seen: &'a HashMap<(String, String), i64>,
pub now: i64,
} }
/// How many shelves the library shows above the grid. /// How many shelves the library shows above the grid.
@@ -481,6 +497,7 @@ impl LibraryView {
if !self.stale && self.progressed && unchanged && !watch_shapes_grid { if !self.stale && self.progressed && unchanged && !watch_shapes_grid {
self.history = ui::history::entries(library); self.history = ui::history::entries(library);
self.resumable = Self::resumable(library); self.resumable = Self::resumable(library);
self.badges = Self::badges(library, &inputs);
self.progressed = false; self.progressed = false;
return; return;
} }
@@ -508,28 +525,44 @@ impl LibraryView {
.enumerate() .enumerate()
.map(|(at, title)| (title.key.as_str(), at)) .map(|(at, title)| (title.key.as_str(), at))
.collect(); .collect();
self.shelves = pstr_core::shelves::pick(&library.titles, metadata, SHELVES) self.listed = inputs
.into_iter() .list
.map(|shelf| { .entries()
let indices = shelf .map(|(key, _)| key.to_owned())
.keys
.iter()
.filter_map(|key| index.get(key.as_str()).copied())
.collect();
(shelf.label, indices)
})
.collect(); .collect();
self.shelves = pstr_core::shelves::home(
&library.titles,
metadata,
inputs.list,
inputs.first_seen,
inputs.now,
SHELVES,
)
.into_iter()
.map(|shelf| {
let indices = shelf
.keys
.iter()
.filter_map(|key| index.get(key.as_str()).copied())
.collect();
(shelf.label, indices)
})
.collect();
} }
let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query) let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query)
.into_iter() .into_iter()
.filter(|title| { .filter(|title| {
self.filter.admits(title, |node| { self.filter.admits(
inputs.offline.contains(&DownloadKey { title,
share_id: node.share_id.clone(), |node| {
link_id: node.link_id.clone(), inputs.offline.contains(&DownloadKey {
}) share_id: node.share_id.clone(),
}) link_id: node.link_id.clone(),
})
},
inputs.list,
)
}) })
.collect(); .collect();
let tiles = pstr_core::browse::arrange( let tiles = pstr_core::browse::arrange(
@@ -564,12 +597,20 @@ impl LibraryView {
if self.stale || self.progressed { if self.stale || self.progressed {
self.history = ui::history::entries(library); self.history = ui::history::entries(library);
self.resumable = Self::resumable(library); self.resumable = Self::resumable(library);
self.badges = Self::badges(library, &inputs);
} }
self.query = inputs.query.to_owned(); self.query = inputs.query.to_owned();
self.stale = false; self.stale = false;
self.progressed = false; self.progressed = false;
} }
fn badges(library: &Library, inputs: &ViewInputs<'_>) -> HashMap<String, String> {
pstr_core::news::badges(&library.titles, inputs.first_seen, inputs.now)
.into_iter()
.map(|(key, badge)| (key, badge.label()))
.collect()
}
/// The "Continue watching" shelf, as indices into the library. /// The "Continue watching" shelf, as indices into the library.
fn resumable(library: &Library) -> Vec<usize> { fn resumable(library: &Library) -> Vec<usize> {
library library
@@ -628,6 +669,9 @@ pub struct App {
pub metadata: HashMap<String, MetadataRecord>, pub metadata: HashMap<String, MetadataRecord>,
/// When each title last gained a file, by key — for "Recently added". /// When each title last gained a file, by key — for "Recently added".
pub added: HashMap<String, i64>, pub added: HashMap<String, i64>,
/// When each file first appeared in a crawl, for the New badges.
first_seen: HashMap<(String, String), i64>,
my_list: MyList,
/// What they said about the episodes under those titles. /// What they said about the episodes under those titles.
pub episodes: HashMap<String, EpisodeGuide>, pub episodes: HashMap<String, EpisodeGuide>,
pub settings: MetadataConfig, pub settings: MetadataConfig,
@@ -723,6 +767,7 @@ impl App {
let window = crate::window::WindowMemory::new(dirs.window_file()); let window = crate::window::WindowMemory::new(dirs.window_file());
let notifier = crate::notify::Notifier::new(&dirs.cache); let notifier = crate::notify::Notifier::new(&dirs.cache);
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?; let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
let my_list = engine.my_list();
theme::install_fonts(&cc.egui_ctx); theme::install_fonts(&cc.egui_ctx);
theme::apply(&cc.egui_ctx, engine.appearance()); theme::apply(&cc.egui_ctx, engine.appearance());
cc.egui_ctx.set_zoom_factor(engine.desktop_prefs().zoom()); cc.egui_ctx.set_zoom_factor(engine.desktop_prefs().zoom());
@@ -752,6 +797,8 @@ impl App {
posters: ImageCache::default(), posters: ImageCache::default(),
metadata: HashMap::new(), metadata: HashMap::new(),
added: HashMap::new(), added: HashMap::new(),
first_seen: HashMap::new(),
my_list,
episodes: HashMap::new(), episodes: HashMap::new(),
settings, settings,
api_key: String::new(), api_key: String::new(),
@@ -886,6 +933,11 @@ impl App {
self.connecting = false; self.connecting = false;
self.note(ctx, format!("could not open the shares: {error}"), true); self.note(ctx, format!("could not open the shares: {error}"), true);
} }
Event::FirstSeen(first_seen) => self.first_seen = first_seen,
Event::MyList(list) => {
self.my_list = list;
self.view.stale = true;
}
Event::LibraryLoaded(library, added) => { Event::LibraryLoaded(library, added) => {
self.library = library; self.library = library;
self.added = added; self.added = added;
@@ -1685,6 +1737,10 @@ impl App {
self.view.grouped = grouped; self.view.grouped = grouped;
self.remember_arrangement(); self.remember_arrangement();
} }
Action::SetOnList { key, on } => {
self.my_list = self.engine.set_on_list(&key, on);
self.view.stale = true;
}
Action::Back => match self.page { Action::Back => match self.page {
Page::Title(_) => self.page = Page::Library, Page::Title(_) => self.page = Page::Library,
// Back out of a search before out of anything else: it is the // Back out of a search before out of anything else: it is the
@@ -2391,6 +2447,9 @@ impl eframe::App for App {
names: self.engine.appearance().names, names: self.engine.appearance().names,
added: &self.added, added: &self.added,
offline: &self.offline_files, offline: &self.offline_files,
list: &self.my_list,
first_seen: &self.first_seen,
now: crate::engine::unix_now(),
}, },
); );
@@ -2519,7 +2578,10 @@ impl eframe::App for App {
&mut art, &mut art,
library, library,
key, key,
view.franchises.get(key.as_str()).map(Vec::as_slice), ui::title::Context {
franchise: view.franchises.get(key.as_str()).map(Vec::as_slice),
on_list: view.listed.contains(key.as_str()),
},
ui::title::OfflineView { ui::title::OfflineView {
downloads, downloads,
files: offline_files, files: offline_files,
+62
View File
@@ -33,6 +33,7 @@ use pstr_core::catalog::{
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::library::{Library, Title, TitleKind}; use pstr_core::library::{Library, Title, TitleKind};
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId}; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
use pstr_core::mylist::MyList;
use pstr_core::prefs::PlaybackPrefs; use pstr_core::prefs::PlaybackPrefs;
use pstr_core::profile::ProfileChanges; use pstr_core::profile::ProfileChanges;
use pstr_core::proton_drive_rs::ThumbnailType; use pstr_core::proton_drive_rs::ThumbnailType;
@@ -221,6 +222,12 @@ pub enum Event {
/// The catalog was re-read: the library, and when each title last /// The catalog was re-read: the library, and when each title last
/// gained a file. /// gained a file.
LibraryLoaded(Library, HashMap<String, i64>), LibraryLoaded(Library, HashMap<String, i64>),
/// When each file first appeared in a crawl, for the New badges; see
/// [`pstr_core::catalog::Catalog::first_seen`]. Sent just before
/// [`Event::LibraryLoaded`].
FirstSeen(HashMap<(String, String), i64>),
/// My List changed on another device.
MyList(MyList),
/// A crawl finished for one share. /// A crawl finished for one share.
Crawled { Crawled {
share_id: String, share_id: String,
@@ -415,6 +422,9 @@ pub struct Engine {
/// Which theme the window wears. Kept beside the playback preferences for /// Which theme the window wears. Kept beside the playback preferences for
/// the same reason: it is read from the UI and written to disk off it. /// the same reason: it is read from the UI and written to disk off it.
appearance: Arc<Mutex<Appearance>>, appearance: Arc<Mutex<Appearance>>,
/// My List, read once at launch. `None` when the file would not parse: it
/// is then never written over, and changing the list is refused.
my_list: Arc<Mutex<Option<MyList>>>,
/// Preloaded so clicking Play never runs SQLite on egui's thread. /// Preloaded so clicking Play never runs SQLite on egui's thread.
track_prefs: Arc<Mutex<HashMap<String, TitleTrackPrefs>>>, track_prefs: Arc<Mutex<HashMap<String, TitleTrackPrefs>>>,
downloads: Arc<Mutex<Downloads>>, downloads: Arc<Mutex<Downloads>>,
@@ -522,6 +532,10 @@ impl Engine {
Appearance::default() Appearance::default()
}); });
let my_list = pstr_core::mylist::load(&dirs)
.inspect_err(|error| tracing::warn!("read My List: {error}"))
.ok();
// The TMDB key, which every provider looks for, comes out of the OS // The TMDB key, which every provider looks for, comes out of the OS
// credential store, and that can be a D-Bus round trip to a keyring // credential store, and that can be a D-Bus round trip to a keyring
// that has to unlock first — not something to hold the first frame // that has to unlock first — not something to hold the first frame
@@ -560,6 +574,7 @@ impl Engine {
enrichment_settled: Arc::new(tokio::sync::watch::channel(!deferred).0), enrichment_settled: Arc::new(tokio::sync::watch::channel(!deferred).0),
prefs: Arc::new(Mutex::new(prefs)), prefs: Arc::new(Mutex::new(prefs)),
appearance: Arc::new(Mutex::new(appearance)), appearance: Arc::new(Mutex::new(appearance)),
my_list: Arc::new(Mutex::new(my_list)),
track_prefs: Arc::new(Mutex::new(track_prefs)), track_prefs: Arc::new(Mutex::new(track_prefs)),
downloads: Arc::new(Mutex::new(Downloads::default())), downloads: Arc::new(Mutex::new(Downloads::default())),
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)), download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
@@ -624,6 +639,43 @@ impl Engine {
*self.appearance.lock() *self.appearance.lock()
} }
/// My List as it stands.
pub fn my_list(&self) -> MyList {
self.my_list.lock().clone().unwrap_or_default()
}
/// Put a title on My List or take it off, write the list, and return it
/// as it now is.
pub fn set_on_list(&self, key: &str, on: bool) -> MyList {
let mut guard = self.my_list.lock();
let Some(list) = guard.as_mut() else {
self.emit(Event::Error(
"My List could not be read, so it was left as it is.".to_owned(),
));
return MyList::default();
};
if on {
list.add(key, unix_now());
} else {
list.remove(key);
}
let saved = list.clone();
drop(guard);
let engine = self.clone();
self.runtime.spawn_blocking(move || {
// Whatever the list is by now, under the lock: two clicks in a row
// must not land on disk in the wrong order.
let guard = engine.my_list.lock();
if let Some(list) = guard.as_ref()
&& let Err(error) = pstr_core::mylist::save(&engine.dirs, list)
{
drop(guard);
engine.fail("save My List", error);
}
});
saved
}
/// Change the theme and write it. /// Change the theme and write it.
/// ///
/// Always committed, unlike the volume: a theme is picked with a click and /// Always committed, unlike the volume: a theme is picked with a click and
@@ -1024,6 +1076,15 @@ impl Engine {
Err(error) => tracing::warn!("reread the metadata settings: {error}"), Err(error) => tracing::warn!("reread the metadata settings: {error}"),
} }
} }
if profile.list {
match pstr_core::mylist::load(&self.dirs) {
Ok(list) => {
*self.my_list.lock() = Some(list.clone());
self.emit(Event::MyList(list));
}
Err(error) => tracing::warn!("reread My List: {error}"),
}
}
if profile.titles > 0 { if profile.titles > 0 {
match self.catalog.lock().all_title_track_prefs() { match self.catalog.lock().all_title_track_prefs() {
Ok(prefs) => *self.track_prefs.lock() = prefs, Ok(prefs) => *self.track_prefs.lock() = prefs,
@@ -1289,6 +1350,7 @@ impl Engine {
let library = Library::build(files, &watch); let library = Library::build(files, &watch);
let targets = download_targets(&library); let targets = download_targets(&library);
let added = library.added_at(&first_seen); let added = library.added_at(&first_seen);
engine.emit(Event::FirstSeen(first_seen));
engine.emit(Event::LibraryLoaded(library, added)); engine.emit(Event::LibraryLoaded(library, added));
engine.refresh_offline(Some(targets)); engine.refresh_offline(Some(targets));
} }
+68 -12
View File
@@ -1,5 +1,7 @@
//! The library page: what to watch, as a wall of stills. //! The library page: what to watch, as a wall of stills.
use std::collections::HashSet;
use pstr_core::appearance::Tiles; use pstr_core::appearance::Tiles;
use pstr_core::library::{Library, Title, TitleKind}; use pstr_core::library::{Library, Title, TitleKind};
@@ -66,7 +68,7 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
.auto_shrink([false, false]) .auto_shrink([false, false])
.show(ui, |ui| { .show(ui, |ui| {
if !featured.is_empty() { if !featured.is_empty() {
hero(ui, art, &featured, actions); hero(ui, art, &featured, &view.listed, actions);
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
} }
if !searching && offer_metadata { if !searching && offer_metadata {
@@ -77,7 +79,7 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
let resumable = pick(&view.resumable); let resumable = pick(&view.resumable);
if !resumable.is_empty() { if !resumable.is_empty() {
ui::section(ui, "Continue watching"); ui::section(ui, "Continue watching");
continue_row(ui, art, &resumable, actions); continue_row(ui, art, &resumable, view, actions);
ui.add_space(theme::space::XL); ui.add_space(theme::space::XL);
} }
// Only over the whole library: under a filter, a shelf of // Only over the whole library: under a filter, a shelf of
@@ -86,7 +88,7 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
if view.filter == Filter::All { if view.filter == Filter::All {
for (label, indices) in &view.shelves { for (label, indices) in &view.shelves {
ui::section(ui, label); ui::section(ui, label);
shelf_row(ui, art, label, &pick(indices), tiles, actions); shelf_row(ui, art, label, &pick(indices), view, tiles, actions);
ui.add_space(theme::space::XL); ui.add_space(theme::space::XL);
} }
} }
@@ -201,7 +203,13 @@ struct Turn {
/// ///
/// Edge to edge rather than inside the page's margin, and square: a banner /// Edge to edge rather than inside the page's margin, and square: a banner
/// in the margin with rounded corners read as one more card. /// in the margin with rounded corners read as one more card.
fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) { fn hero(
ui: &mut egui::Ui,
art: &mut Art<'_>,
titles: &[&Title],
listed: &HashSet<String>,
actions: &mut Vec<Action>,
) {
let height = (ui.ctx().content_rect().height() * 0.5).clamp(280.0, 520.0); let height = (ui.ctx().content_rect().height() * 0.5).clamp(280.0, 520.0);
let (column, _) = ui.allocate_exact_size( let (column, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), height), egui::vec2(ui.available_width(), height),
@@ -315,7 +323,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
.layout(egui::Layout::bottom_up(egui::Align::Min)), .layout(egui::Layout::bottom_up(egui::Align::Min)),
|ui| { |ui| {
ui.multiply_opacity(arriving); ui.multiply_opacity(arriving);
hero_text(ui, art, title, actions); hero_text(ui, art, title, listed.contains(&title.key), actions);
}, },
); );
@@ -413,7 +421,13 @@ fn backdrop(
/// What the banner says about one title, from the bottom up: how far into it /// What the banner says about one title, from the bottom up: how far into it
/// the viewer is, the buttons, the synopsis, the facts and the name. /// the viewer is, the buttons, the synopsis, the facts and the name.
fn hero_text(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) { fn hero_text(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
on_list: bool,
actions: &mut Vec<Action>,
) {
let found = art let found = art
.metadata .metadata
.get(&title.key) .get(&title.key)
@@ -448,6 +462,8 @@ fn hero_text(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut
if ui::tonal_button(ui, &more).clicked() { if ui::tonal_button(ui, &more).clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone()))); actions.push(Action::Goto(Page::Title(title.key.clone())));
} }
ui.add_space(theme::space::S);
ui::list_button(ui, &title.key, on_list, actions);
}); });
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) { if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) {
@@ -614,10 +630,12 @@ fn shelf_row(
art: &mut Art<'_>, art: &mut Art<'_>,
label: &str, label: &str,
titles: &[&Title], titles: &[&Title],
view: &LibraryView,
tiles: Tiles, tiles: Tiles,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
let (width, aspect) = tile_shape(tiles); let (width, aspect) = tile_shape(tiles);
let now = crate::engine::unix_now();
ui::shelf(ui, ("shelf", label), |ui| { ui::shelf(ui, ("shelf", label), |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP; ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
@@ -633,12 +651,16 @@ fn shelf_row(
name: art.name(title), name: art.name(title),
subtitle: subtitle(title), subtitle: subtitle(title),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: None, badge: view
.badges
.get(&title.key)
.cloned()
.or_else(|| countdown(art, title, now)),
width, width,
aspect, aspect,
}, },
); );
tile_menu(&response, title, false, actions); tile_menu(&response, title, false, view, actions);
if response.clicked() { if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone()))); actions.push(Action::Goto(Page::Title(title.key.clone())));
} }
@@ -647,6 +669,19 @@ fn shelf_row(
}); });
} }
/// `Ep 8 · in 2 d` on a show whose next episode is still to air.
fn countdown(art: &Art<'_>, title: &Title, now: i64) -> Option<String> {
let (episode, at) = art
.metadata
.get(&title.key)?
.metadata
.as_ref()?
.details
.as_ref()?
.next_airing(now)?;
Some(pstr_core::news::countdown(episode, at, now))
}
/// The minimum width and the picture's shape of a tile. /// The minimum width and the picture's shape of a tile.
fn tile_shape(tiles: Tiles) -> (f32, f32) { fn tile_shape(tiles: Tiles) -> (f32, f32) {
match tiles { match tiles {
@@ -660,6 +695,7 @@ fn continue_row(
ui: &mut egui::Ui, ui: &mut egui::Ui,
art: &mut Art<'_>, art: &mut Art<'_>,
titles: &[&Title], titles: &[&Title],
view: &LibraryView,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
ui::shelf(ui, "continue", |ui| { ui::shelf(ui, "continue", |ui| {
@@ -693,7 +729,7 @@ fn continue_row(
aspect: theme::CARD_ASPECT, aspect: theme::CARD_ASPECT,
}, },
); );
tile_menu(&response, title, true, actions); tile_menu(&response, title, true, view, actions);
if response.clicked() { if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone()))); actions.push(Action::Goto(Page::Title(title.key.clone())));
@@ -758,13 +794,18 @@ fn grid(
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
// What a film is says itself in the subtitle below the // What a film is says itself in the subtitle below the
// card; a second `Film` over the poster is noise. A // card; a second `Film` over the poster is noise. A
// folded franchise says how much is under it. // folded franchise says how much is under it, unless
badge: folded.map(|more| format!("+{more}")), // something new arrived in it.
badge: view
.badges
.get(&title.key)
.cloned()
.or_else(|| folded.map(|more| format!("+{more}"))),
width: grid.width, width: grid.width,
aspect, aspect,
}, },
); );
tile_menu(&response, title, false, actions); tile_menu(&response, title, false, view, actions);
if response.clicked() { if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone()))); actions.push(Action::Goto(Page::Title(title.key.clone())));
@@ -783,6 +824,7 @@ fn tile_menu(
response: &egui::Response, response: &egui::Response,
title: &Title, title: &Title,
continuing: bool, continuing: bool,
view: &LibraryView,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
response.context_menu(|ui| { response.context_menu(|ui| {
@@ -799,6 +841,20 @@ fn tile_menu(
if ui.button("Open").clicked() { if ui.button("Open").clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone()))); actions.push(Action::Goto(Page::Title(title.key.clone())));
} }
let on_list = view.listed.contains(&title.key);
if ui
.button(if on_list {
"Remove from My List"
} else {
"Add to My List"
})
.clicked()
{
actions.push(Action::SetOnList {
key: title.key.clone(),
on: !on_list,
});
}
ui.separator(); ui.separator();
let all_watched = title.watched_count() == title.episode_count(); let all_watched = title.watched_count() == title.episode_count();
+22
View File
@@ -23,6 +23,7 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2};
use pstr_core::library::{Episode, Title}; use pstr_core::library::{Episode, Title};
use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord, TitleNames}; use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord, TitleNames};
use crate::app::Action;
use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key}; use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key};
use crate::theme; use crate::theme;
use widgets::Named; use widgets::Named;
@@ -375,6 +376,27 @@ pub fn tonal_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
filled(ui, text, Fill::Tonal, Vec2::new(0.0, 0.0)) filled(ui, text, Fill::Tonal, Vec2::new(0.0, 0.0))
} }
/// My List's button: `+ My List` to put the title on it, `✓ My List` once it
/// is, which takes it off again.
pub fn list_button(ui: &mut egui::Ui, key: &str, on_list: bool, actions: &mut Vec<Action>) {
let label = if on_list {
format!("{} My List", egui_phosphor::regular::CHECK)
} else {
format!("{} My List", egui_phosphor::regular::PLUS)
};
let response = tonal_button(ui, &label).on_hover_text(if on_list {
"On My List. Click to take it off"
} else {
"Put it on My List, to find it again on every device"
});
if response.clicked() {
actions.push(Action::SetOnList {
key: key.to_owned(),
on: !on_list,
});
}
}
/// A button laid over the picture: dark glass and white ink, whatever the /// A button laid over the picture: dark glass and white ink, whatever the
/// theme, because what is under it is a video frame and not the page. /// theme, because what is under it is a video frame and not the page.
pub fn glass_button(ui: &mut egui::Ui, text: &str) -> egui::Response { pub fn glass_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
+12 -2
View File
@@ -16,15 +16,23 @@ pub struct OfflineView<'a> {
pub files: &'a std::collections::HashSet<DownloadKey>, pub files: &'a std::collections::HashSet<DownloadKey>,
} }
/// Where the title stands beyond its own files.
pub struct Context<'a> {
/// The keys of its whole franchise in release order, if it has one.
pub franchise: Option<&'a [String]>,
pub on_list: bool,
}
pub fn show( pub fn show(
ui: &mut egui::Ui, ui: &mut egui::Ui,
art: &mut Art<'_>, art: &mut Art<'_>,
library: &Library, library: &Library,
key: &str, key: &str,
franchise: Option<&[String]>, context: Context<'_>,
offline: OfflineView<'_>, offline: OfflineView<'_>,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
let Context { franchise, on_list } = context;
let Some(title) = library.get(key) else { let Some(title) = library.get(key) else {
// The catalog was replaced under the page — a recrawl that dropped this // The catalog was replaced under the page — a recrawl that dropped this
// title. Nothing to show, so go back rather than draw an empty shell. // title. Nothing to show, so go back rather than draw an empty shell.
@@ -40,7 +48,7 @@ pub fn show(
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false, false]) .auto_shrink([false, false])
.show(ui, |ui| { .show(ui, |ui| {
header(ui, art, title, &offline, actions); header(ui, art, title, on_list, &offline, actions);
ui.add_space(theme::space::XL); ui.add_space(theme::space::XL);
if let Some(keys) = franchise { if let Some(keys) = franchise {
@@ -289,6 +297,7 @@ fn header(
ui: &mut egui::Ui, ui: &mut egui::Ui,
art: &mut Art<'_>, art: &mut Art<'_>,
title: &Title, title: &Title,
on_list: bool,
offline: &OfflineView<'_>, offline: &OfflineView<'_>,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
@@ -425,6 +434,7 @@ fn header(
None, None,
))); )));
} }
ui::list_button(ui, &title.key, on_list, actions);
let title_keys: Vec<_> = title.episodes().map(key_of).collect(); let title_keys: Vec<_> = title.episodes().map(key_of).collect();
let all_offline = title_keys.iter().all(|key| offline.files.contains(key)); let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
if all_offline { if all_offline {
+37 -9
View File
@@ -10,6 +10,7 @@ use std::collections::{HashMap, HashSet};
use crate::catalog::CatalogNode; use crate::catalog::CatalogNode;
use crate::library::{Library, Title, TitleKind, sort_name}; use crate::library::{Library, Title, TitleKind, sort_name};
use crate::metadata::{MetadataRecord, TitleNames}; use crate::metadata::{MetadataRecord, TitleNames};
use crate::mylist::MyList;
/// What order a grid is in. /// What order a grid is in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
@@ -66,11 +67,14 @@ pub enum Filter {
Unwatched, Unwatched,
/// With at least one episode kept on this device. /// With at least one episode kept on this device.
Downloaded, Downloaded,
/// On My List.
MyList,
} }
impl Filter { impl Filter {
pub const ALL: [Self; 6] = [ pub const ALL: [Self; 7] = [
Self::All, Self::All,
Self::MyList,
Self::Series, Self::Series,
Self::Films, Self::Films,
Self::Watching, Self::Watching,
@@ -86,12 +90,18 @@ impl Filter {
Self::Watching => "Watching", Self::Watching => "Watching",
Self::Unwatched => "Not started", Self::Unwatched => "Not started",
Self::Downloaded => "Downloaded", Self::Downloaded => "Downloaded",
Self::MyList => "My List",
} }
} }
/// Whether the grid shows `title`. `offline` says whether a file is kept /// Whether the grid shows `title`. `offline` says whether a file is kept
/// on this device, which only the front end knows. /// on this device, which only the front end knows.
pub fn admits(self, title: &Title, offline: impl Fn(&CatalogNode) -> bool) -> bool { pub fn admits(
self,
title: &Title,
offline: impl Fn(&CatalogNode) -> bool,
list: &MyList,
) -> bool {
let watched = title.watched_count(); let watched = title.watched_count();
match self { match self {
Self::All => true, Self::All => true,
@@ -102,6 +112,7 @@ impl Filter {
} }
Self::Unwatched => watched == 0 && title.resume().is_none(), Self::Unwatched => watched == 0 && title.resume().is_none(),
Self::Downloaded => title.episodes().any(|episode| offline(&episode.node)), Self::Downloaded => title.episodes().any(|episode| offline(&episode.node)),
Self::MyList => list.contains(&title.key),
} }
} }
} }
@@ -455,12 +466,18 @@ mod tests {
let film = title("f", "Film", TitleKind::Film); let film = title("f", "Film", TitleKind::Film);
let series = title("s", "Series", TitleKind::Series); let series = title("s", "Series", TitleKind::Series);
let none = |_: &CatalogNode| false; let none = |_: &CatalogNode| false;
assert!(Filter::All.admits(&film, none) && Filter::All.admits(&series, none)); let list = MyList::default();
assert!(Filter::Films.admits(&film, none) && !Filter::Films.admits(&series, none)); assert!(Filter::All.admits(&film, none, &list) && Filter::All.admits(&series, none, &list));
assert!(Filter::Series.admits(&series, none) && !Filter::Series.admits(&film, none)); assert!(
Filter::Films.admits(&film, none, &list) && !Filter::Films.admits(&series, none, &list)
);
assert!(
Filter::Series.admits(&series, none, &list)
&& !Filter::Series.admits(&film, none, &list)
);
// Nothing watched of either: not started, and not being watched. // Nothing watched of either: not started, and not being watched.
assert!(Filter::Unwatched.admits(&film, none)); assert!(Filter::Unwatched.admits(&film, none, &list));
assert!(!Filter::Watching.admits(&film, none)); assert!(!Filter::Watching.admits(&film, none, &list));
} }
#[test] #[test]
@@ -486,7 +503,18 @@ mod tests {
let library = Library::build(vec![node("a", 1), node("b", 2)], &HashMap::new()); let library = Library::build(vec![node("a", 1), node("b", 2)], &HashMap::new());
let show = &library.titles[0]; let show = &library.titles[0];
assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b")); let list = MyList::default();
assert!(!Filter::Downloaded.admits(show, |_| false)); assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b", &list));
assert!(!Filter::Downloaded.admits(show, |_| false, &list));
}
#[test]
fn the_my_list_filter_admits_only_what_is_on_the_list() {
let film = title("f", "Film", TitleKind::Film);
let series = title("s", "Series", TitleKind::Series);
let mut list = MyList::default();
list.add("s", 1);
assert!(Filter::MyList.admits(&series, |_| false, &list));
assert!(!Filter::MyList.admits(&film, |_| false, &list));
} }
} }
+2
View File
@@ -16,7 +16,9 @@ pub mod franchise;
pub mod language; pub mod language;
pub mod library; pub mod library;
pub mod metadata; pub mod metadata;
pub mod mylist;
pub mod naming; pub mod naming;
pub mod news;
pub mod prefs; pub mod prefs;
pub mod profile; pub mod profile;
pub mod shares; pub mod shares;
+141
View File
@@ -0,0 +1,141 @@
//! My List: the titles the viewer put aside to watch, kept in
//! `my-list.json` beside the other settings.
//!
//! Keyed by title key, like a hand-picked match, so a title keeps its place on
//! the list across a recrawl and on every device that has the same share. A
//! title that leaves the library stays on the list, unseen, and is back on it
//! when the share brings it back.
//!
//! It travels with the profile, one `list/<title key>` register per title
//! ([`crate::profile`]): adding a title on the phone puts it on the laptop's
//! list, and taking it off on one takes it off on all of them.
use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result;
/// The titles on the list, by key, with when each was put there in Unix
/// seconds.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MyList {
#[serde(default)]
titles: BTreeMap<String, i64>,
}
impl MyList {
pub fn contains(&self, key: &str) -> bool {
self.titles.contains_key(key)
}
pub fn is_empty(&self) -> bool {
self.titles.is_empty()
}
/// When `key` was put on the list, if it is on it.
pub fn added_at(&self, key: &str) -> Option<i64> {
self.titles.get(key).copied()
}
/// Put `key` on the list at `at`. One already there keeps its place.
pub fn add(&mut self, key: &str, at: i64) {
self.titles.entry(key.to_owned()).or_insert(at);
}
pub fn remove(&mut self, key: &str) {
self.titles.remove(key);
}
/// Put `key` on the list or take it off, saying which.
pub fn toggle(&mut self, key: &str, at: i64) -> bool {
if self.contains(key) {
self.remove(key);
false
} else {
self.add(key, at);
true
}
}
/// Every key with when it was added, in key order.
pub fn entries(&self) -> impl Iterator<Item = (&str, i64)> {
self.titles.iter().map(|(key, at)| (key.as_str(), *at))
}
/// The keys, most recently added first: the order the row is in.
pub fn newest_first(&self) -> Vec<&str> {
let mut keys: Vec<(&str, i64)> = self.entries().collect();
keys.sort_by_key(|(_, at)| std::cmp::Reverse(*at));
keys.into_iter().map(|(key, _)| key).collect()
}
/// Set `key`'s entry as another device had it: on the list since `at`,
/// or, with `None`, off it.
pub(crate) fn set(&mut self, key: &str, at: Option<i64>) {
match at {
Some(at) => {
self.titles.insert(key.to_owned(), at);
}
None => self.remove(key),
}
}
}
pub(crate) fn file(dirs: &AppDirs) -> PathBuf {
dirs.config.join("my-list.json")
}
/// The list, empty when there is no file yet.
pub fn load(dirs: &AppDirs) -> Result<MyList> {
Ok(read_json::<MyList>(&file(dirs))?.unwrap_or_default())
}
pub fn save(dirs: &AppDirs, list: &MyList) -> Result<()> {
write_json(&file(dirs), list)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_title_added_twice_keeps_the_time_it_was_first_added() {
let mut list = MyList::default();
list.add("frieren", 10);
list.add("frieren", 20);
assert_eq!(list.added_at("frieren"), Some(10));
}
#[test]
fn toggling_puts_a_title_on_the_list_and_takes_it_off_again() {
let mut list = MyList::default();
assert!(list.toggle("frieren", 10));
assert!(list.contains("frieren"));
assert!(!list.toggle("frieren", 20));
assert!(list.is_empty());
}
#[test]
fn the_row_shows_the_most_recently_added_title_first() {
let mut list = MyList::default();
list.add("a", 30);
list.add("b", 10);
list.add("c", 20);
assert_eq!(list.newest_first(), ["a", "c", "b"]);
}
#[test]
fn the_list_survives_a_save_and_a_load() {
let root = std::env::temp_dir().join(format!("pstr-mylist-{}", std::process::id()));
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
assert!(load(&dirs).unwrap().is_empty());
let mut list = MyList::default();
list.add("one piece", 5);
save(&dirs, &list).unwrap();
assert_eq!(load(&dirs).unwrap(), list);
let _ = std::fs::remove_dir_all(&root);
}
}
+304
View File
@@ -0,0 +1,304 @@
//! What arrived since the viewer last looked: the **New** and **3 new**
//! badges, the "New episodes" and "Recently added" rows, and the countdown on
//! an airing show's tile.
//!
//! All of it is read off [`crate::catalog::Catalog::first_seen`], the time a
//! crawl first found each file. A file the first crawl of its share found is
//! not new, however recent that crawl: it was there before this device looked,
//! and a share added today would otherwise badge its whole library.
use std::collections::HashMap;
use crate::library::{Episode, Title, TitleKind};
use crate::shelves::Shelf;
/// How long a title that arrived stays **New**, unless it is started first.
pub const NEW_FOR: i64 = 14 * 86_400;
/// What a tile says about what arrived.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Badge {
/// A title that arrived in the last [`NEW_FOR`] and was never played.
New,
/// A series being watched, with this many unwatched episodes that arrived
/// after it was last played.
Episodes(usize),
}
impl Badge {
pub fn label(self) -> String {
match self {
Self::New => "New".to_owned(),
Self::Episodes(count) => format!("{count} new"),
}
}
}
/// When each file arrived, read against when its share was first crawled.
pub struct Arrivals<'a> {
first_seen: &'a HashMap<(String, String), i64>,
/// The earliest sighting in each share: its first crawl.
first_crawl: HashMap<&'a str, i64>,
}
impl<'a> Arrivals<'a> {
pub fn new(first_seen: &'a HashMap<(String, String), i64>) -> Self {
let mut first_crawl: HashMap<&str, i64> = HashMap::new();
for ((share, _), seen) in first_seen {
first_crawl
.entry(share.as_str())
.and_modify(|first| *first = (*first).min(*seen))
.or_insert(*seen);
}
Self {
first_seen,
first_crawl,
}
}
/// When `episode`'s file arrived, if a crawl after its share's first one
/// found it.
pub fn arrived(&self, episode: &Episode) -> Option<i64> {
let node = &episode.node;
let seen = *self
.first_seen
.get(&(node.share_id.clone(), node.link_id.clone()))?;
let first = self.first_crawl.get(node.share_id.as_str())?;
(seen > *first).then_some(seen)
}
/// The badge for `title`, if it has one.
pub fn badge(&self, title: &Title, now: i64) -> Option<Badge> {
let played = title.last_played();
if played == 0 {
// New only if every file of it arrived: a show the first crawl
// found is not new because a crawl since added an episode.
let arrived: Option<Vec<i64>> = title
.episodes()
.map(|episode| self.arrived(episode))
.collect();
let first = arrived?.into_iter().min()?;
return (now - first < NEW_FOR).then_some(Badge::New);
}
if title.kind != TitleKind::Series {
return None;
}
let count = title
.episodes()
.filter(|episode| !episode.is_watched())
.filter(|episode| self.arrived(episode).is_some_and(|at| at > played))
.count();
(count > 0).then_some(Badge::Episodes(count))
}
/// When `title` last gained a file that counts as arrived.
fn newest(&self, title: &Title) -> Option<i64> {
title
.episodes()
.filter_map(|episode| self.arrived(episode))
.max()
}
}
/// Every badge in `titles`, by key.
pub fn badges(
titles: &[Title],
first_seen: &HashMap<(String, String), i64>,
now: i64,
) -> HashMap<String, Badge> {
let arrivals = Arrivals::new(first_seen);
titles
.iter()
.filter_map(|title| Some((title.key.clone(), arrivals.badge(title, now)?)))
.collect()
}
/// The two rows of what arrived, newest arrival first. `None` for a row with
/// nothing in it.
#[derive(Debug, Default)]
pub struct Rows {
/// "New episodes": the shows being watched that gained episodes.
pub episodes: Option<Shelf>,
/// "Recently added": the titles that are [`Badge::New`].
pub added: Option<Shelf>,
}
pub fn rows(titles: &[Title], first_seen: &HashMap<(String, String), i64>, now: i64) -> Rows {
let arrivals = Arrivals::new(first_seen);
let mut episodes: Vec<(&Title, i64)> = Vec::new();
let mut added: Vec<(&Title, i64)> = Vec::new();
for title in titles {
let (Some(badge), Some(newest)) = (arrivals.badge(title, now), arrivals.newest(title))
else {
continue;
};
match badge {
Badge::Episodes(_) => episodes.push((title, newest)),
Badge::New => added.push((title, newest)),
}
}
let shelf = |label: &str, mut row: Vec<(&Title, i64)>| {
row.sort_by_key(|(_, newest)| std::cmp::Reverse(*newest));
(!row.is_empty()).then(|| Shelf {
label: label.to_owned(),
keys: row
.into_iter()
.map(|(title, _)| title.key.clone())
.collect(),
})
};
Rows {
episodes: shelf("New episodes", episodes),
added: shelf("Recently added", added),
}
}
/// What an airing show's tile says about its next episode: `Ep 8 · in 2 d`,
/// `Ep 8 · in 5 h`, `Ep 8 · soon`.
pub fn countdown(episode: u32, at: i64, now: i64) -> String {
let left = (at - now).max(0);
let when = match left {
0..3_600 => "soon".to_owned(),
3_600..86_400 => format!("in {} h", left / 3_600),
_ => format!("in {} d", left / 86_400),
};
format!("Ep {episode} · {when}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{CatalogNode, WatchState};
use crate::library::Library;
const DAY: i64 = 86_400;
fn file(share: &str, link: &str, title: &str, episode: u32) -> CatalogNode {
CatalogNode {
share_id: share.into(),
link_id: link.into(),
volume_id: "v".into(),
parent_link_id: None,
name: format!("{title} - {episode:02}.mkv"),
is_folder: false,
media_type: Some("video/x-matroska".into()),
size: Some(1),
active_revision_id: Some("r".into()),
parsed: crate::naming::parse(&format!("{title} - {episode:02}.mkv")),
}
}
fn watched(at: i64) -> WatchState {
WatchState {
position_secs: 1_400.0,
duration_secs: Some(1_440.0),
watched: true,
updated_at: at,
}
}
/// One share whose first crawl at day 1 found Frieren 1–2, and Dandadan 1
/// arriving at day 20 next to Frieren 3.
fn share(frieren_played: Option<i64>) -> (Library, HashMap<(String, String), i64>) {
let files = vec![
file("s", "f1", "Frieren", 1),
file("s", "f2", "Frieren", 2),
file("s", "f3", "Frieren", 3),
file("s", "d1", "Dandadan", 1),
];
let mut watch = HashMap::new();
if let Some(at) = frieren_played {
watch.insert(("s".to_owned(), "f1".to_owned()), watched(at));
watch.insert(("s".to_owned(), "f2".to_owned()), watched(at));
}
let seen = [("f1", 1), ("f2", 1), ("f3", 20), ("d1", 20)]
.into_iter()
.map(|(link, day)| (("s".to_owned(), link.to_owned()), day * DAY))
.collect();
(Library::build(files, &watch), seen)
}
fn named<'a>(library: &'a Library, name: &str) -> &'a Title {
library
.titles
.iter()
.find(|title| title.name == name)
.unwrap()
}
#[test]
fn what_the_first_crawl_of_a_share_found_is_never_new() {
let (library, seen) = share(None);
let arrivals = Arrivals::new(&seen);
let frieren = named(&library, "Frieren");
assert_eq!(arrivals.badge(frieren, 21 * DAY), None);
}
#[test]
fn a_title_that_arrived_is_new_for_two_weeks_unless_it_is_started() {
let (library, seen) = share(None);
let arrivals = Arrivals::new(&seen);
let dandadan = named(&library, "Dandadan");
assert_eq!(arrivals.badge(dandadan, 21 * DAY), Some(Badge::New));
assert_eq!(arrivals.badge(dandadan, 20 * DAY + NEW_FOR), None);
}
#[test]
fn a_show_being_watched_counts_the_episodes_that_arrived_after_the_last_watch() {
let (library, seen) = share(Some(10 * DAY));
let arrivals = Arrivals::new(&seen);
let frieren = named(&library, "Frieren");
assert_eq!(arrivals.badge(frieren, 21 * DAY), Some(Badge::Episodes(1)));
assert_eq!(Badge::Episodes(1).label(), "1 new");
// Watched after episode 3 arrived: nothing is new to the viewer.
let (library, seen) = share(Some(25 * DAY));
let arrivals = Arrivals::new(&seen);
assert_eq!(arrivals.badge(named(&library, "Frieren"), 26 * DAY), None);
}
#[test]
fn the_rows_hold_new_episodes_and_recent_arrivals_and_leave_out_an_empty_one() {
let (library, seen) = share(Some(10 * DAY));
let found = rows(&library.titles, &seen, 21 * DAY);
let labels: Vec<(&str, Vec<&str>)> = [&found.episodes, &found.added]
.into_iter()
.flatten()
.map(|row| {
let names = row
.keys
.iter()
.map(|key| {
library
.titles
.iter()
.find(|title| &title.key == key)
.unwrap()
.name
.as_str()
})
.collect();
(row.label.as_str(), names)
})
.collect();
assert_eq!(
labels,
[
("New episodes", vec!["Frieren"]),
("Recently added", vec!["Dandadan"])
]
);
let (library, seen) = share(None);
let found = rows(&library.titles, &seen, 21 * DAY);
assert!(found.episodes.is_none());
assert_eq!(found.added.unwrap().label, "Recently added");
}
#[test]
fn the_countdown_says_days_then_hours_then_soon() {
assert_eq!(countdown(8, 3 * DAY + 100, 0), "Ep 8 · in 3 d");
assert_eq!(countdown(8, 5 * 3_600 + 10, 0), "Ep 8 · in 5 h");
assert_eq!(countdown(8, 600, 0), "Ep 8 · soon");
}
}
+95 -6
View File
@@ -1,6 +1,6 @@
//! The rest of what makes the app look the same on every device: the shares, //! The rest of what makes the app look the same on every device: the shares,
//! the settings, the hand-picked metadata matches and the per-show track //! the settings, the hand-picked metadata matches, the per-show track choices
//! choices. Synced next to watch history by [`crate::sync`], in the same file. //! and My List. Synced next to watch history by [`crate::sync`], in the same file.
//! //!
//! ## Registers //! ## Registers
//! //!
@@ -8,8 +8,8 @@
//! `settings/playback`, `tracks/<title>`, …), its latest value, and when it //! `settings/playback`, `tracks/<title>`, …), its latest value, and when it
//! last changed. Merging is the same rule as watch history — the newer //! last changed. Merging is the same rule as watch history — the newer
//! `updated_at` wins, per key — so it is idempotent and order-free across any //! `updated_at` wins, per key — so it is idempotent and order-free across any
//! number of devices. A removed share is a register with no value, so a //! number of devices. A removed share, or a title taken off My List, is a
//! removal travels like any other change. //! register with no value, so a removal travels like any other change.
//! //!
//! ## Noticing changes //! ## Noticing changes
//! //!
@@ -56,6 +56,7 @@ use crate::shares::{AccountFolder, Share, ShareSecrets, ShareStore};
const SHARE: &str = "share/"; const SHARE: &str = "share/";
const TRACKS: &str = "tracks/"; const TRACKS: &str = "tracks/";
const MATCH: &str = "match/"; const MATCH: &str = "match/";
const LIST: &str = "list/";
/// The settings files that travel, by register, and the fields in each that /// The settings files that travel, by register, and the fields in each that
/// stay on the device that wrote them. /// stay on the device that wrote them.
@@ -98,6 +99,12 @@ struct SyncedShare {
folder: Option<AccountFolder>, folder: Option<AccountFolder>,
} }
/// A title on My List.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SyncedListEntry {
added_at: i64,
}
/// A match the viewer picked by hand. Automatic ones are not synced: every /// A match the viewer picked by hand. Automatic ones are not synced: every
/// device with enrichment on finds them again, and they expire anyway. /// device with enrichment on finds them again, and they expire anyway.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -123,6 +130,8 @@ pub struct ProfileChanges {
pub settings: bool, pub settings: bool,
/// Hand-picked matches and per-show track choices taken. /// Hand-picked matches and per-show track choices taken.
pub titles: usize, pub titles: usize,
/// Whether My List changed.
pub list: bool,
} }
impl ProfileChanges { impl ProfileChanges {
@@ -154,6 +163,8 @@ struct Observed {
/// Whether the share list itself could be read. Without it, a share /// Whether the share list itself could be read. Without it, a share
/// missing from `values` says nothing about whether it was removed. /// missing from `values` says nothing about whether it was removed.
shares_listed: bool, shares_listed: bool,
/// The same for My List.
list_read: bool,
} }
fn state_file(dirs: &AppDirs) -> PathBuf { fn state_file(dirs: &AppDirs) -> PathBuf {
@@ -248,6 +259,18 @@ impl Profile<'_> {
} }
} }
match crate::mylist::load(self.dirs()) {
Ok(list) => {
observed.list_read = true;
for (title, added_at) in list.entries() {
if let Ok(value) = serde_json::to_value(SyncedListEntry { added_at }) {
observed.values.insert(format!("{LIST}{title}"), value);
}
}
}
Err(error) => tracing::warn!(%error, "My List was not synced"),
}
let catalog = self.catalog.lock(); let catalog = self.catalog.lock();
if let Ok(tracks) = catalog.all_title_track_prefs() { if let Ok(tracks) = catalog.all_title_track_prefs() {
for (title, prefs) in tracks { for (title, prefs) in tracks {
@@ -320,6 +343,19 @@ impl Profile<'_> {
} }
return Ok(()); return Ok(());
} }
if let Some(title) = key.strip_prefix(LIST) {
let added_at = value
.map(parse::<SyncedListEntry>)
.transpose()?
.map(|entry| entry.added_at);
let mut list = crate::mylist::load(self.dirs())?;
if list.added_at(title) != added_at {
list.set(title, added_at);
crate::mylist::save(self.dirs(), &list)?;
changes.list = true;
}
return Ok(());
}
if key == TMDB_KEY { if key == TMDB_KEY {
match value.and_then(Value::as_str) { match value.and_then(Value::as_str) {
Some(secret) => self.store.secrets.set(&tmdb_secret(), secret)?, Some(secret) => self.store.secrets.set(&tmdb_secret(), secret)?,
@@ -428,9 +464,10 @@ impl Profile<'_> {
/// Record what changed on this device since the last sync, stamped `now`. /// Record what changed on this device since the last sync, stamped `now`.
fn stamp(registers: &mut Registers, observed: Observed, now: i64) { fn stamp(registers: &mut Registers, observed: Observed, now: i64) {
for (key, register) in registers.iter_mut() { for (key, register) in registers.iter_mut() {
let removable = (key.starts_with(SHARE) && observed.shares_listed)
|| (key.starts_with(LIST) && observed.list_read);
if register.value.is_some() if register.value.is_some()
&& key.starts_with(SHARE) && removable
&& observed.shares_listed
&& !observed.values.contains_key(key) && !observed.values.contains_key(key)
&& !observed.unknown.contains(key) && !observed.unknown.contains(key)
{ {
@@ -789,6 +826,58 @@ pub(crate) mod tests {
); );
} }
#[test]
fn a_title_put_on_my_list_on_one_device_is_on_the_list_of_another() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 1);
let mut list = crate::mylist::MyList::default();
list.add("frieren", 5);
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (_, changes) = phone.sync(&[&sent], 20);
assert!(changes.list);
let taken = crate::mylist::load(&phone.store.dirs).unwrap();
assert_eq!(taken.added_at("frieren"), Some(5));
}
#[test]
fn a_title_taken_off_my_list_on_one_device_leaves_every_list() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
let mut list = crate::mylist::MyList::default();
list.add("frieren", 5);
list.add("one piece", 6);
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (phone_sent, _) = phone.sync(&[&sent], 20);
let mut list = crate::mylist::load(&phone.store.dirs).unwrap();
list.remove("frieren");
crate::mylist::save(&phone.store.dirs, &list).unwrap();
let (phone_sent, _) = phone.sync(&[&sent, &phone_sent], 30);
let (sent, changes) = laptop.sync(&[&phone_sent], 40);
assert!(changes.list);
let left = crate::mylist::load(&laptop.store.dirs).unwrap();
assert!(!left.contains("frieren"));
assert!(left.contains("one piece"));
// Put back later, it is back everywhere: the removal is older.
let mut list = left;
list.add("frieren", 50);
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
let (sent, _) = laptop.sync(&[&sent], 50);
phone.sync(&[&sent], 60);
assert!(
crate::mylist::load(&phone.store.dirs)
.unwrap()
.contains("frieren")
);
}
#[test] #[test]
fn a_synced_share_that_names_another_id_is_refused() { fn a_synced_share_that_names_another_id_is_refused() {
let phone = Device::new("phone"); let phone = Device::new("phone");
+45
View File
@@ -135,6 +135,38 @@ pub fn pick(
picked picked
} }
/// Every row above the library grid, in the order all three apps show them:
/// new episodes of what is being watched, My List, what was recently added,
/// then up to `limit` [`pick`]ed shelves.
pub fn home(
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
list: &crate::mylist::MyList,
first_seen: &HashMap<(String, String), i64>,
now: i64,
limit: usize,
) -> Vec<Shelf> {
let news = crate::news::rows(titles, first_seen, now);
let present: std::collections::HashSet<&str> =
titles.iter().map(|title| title.key.as_str()).collect();
let keys: Vec<String> = list
.newest_first()
.into_iter()
.filter(|key| present.contains(key))
.map(str::to_owned)
.collect();
let mine = (!keys.is_empty()).then(|| Shelf {
label: "My List".into(),
keys,
});
news.episodes
.into_iter()
.chain(mine)
.chain(news.added)
.chain(pick(titles, metadata, limit))
.collect()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -259,4 +291,17 @@ mod tests {
assert_eq!(shelves[0].label, "Airing now"); assert_eq!(shelves[0].label, "Airing now");
assert_eq!(shelves[0].keys, vec!["a"]); assert_eq!(shelves[0].keys, vec!["a"]);
} }
#[test]
fn my_list_is_a_row_of_what_the_library_holds_newest_first() {
let titles = vec![title("a"), title("b")];
let mut list = crate::mylist::MyList::default();
list.add("a", 1);
list.add("b", 3);
list.add("gone", 5);
let rows = home(&titles, &HashMap::new(), &list, &HashMap::new(), 10, 4);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].label, "My List");
assert_eq!(rows[0].keys, ["b", "a"]);
}
} }
+5 -3
View File
@@ -4,8 +4,8 @@ Signing in to Proton is optional. Without it, the app plays public share links a
everything on one device. Signing in adds two things: everything on one device. Signing in adds two things:
- **Folders of your own Drive** as shares, with no link to copy. - **Folders of your own Drive** as shares, with no link to copy.
- **Sync** of your place in every episode, your shares and your settings, across every device - **Sync** of your place in every episode, your shares, your settings and My List, across every
you sign in on. device you sign in on.
## Signing in ## Signing in
@@ -51,6 +51,8 @@ When you are signed in, each device keeps a small file in your Drive. You find t
your Drive) your Drive)
- Settings: playback, appearance and posters and descriptions, including a TMDB key - Settings: playback, appearance and posters and descriptions, including a TMDB key
- Matches you picked by hand, and your audio and subtitle choice for each show - Matches you picked by hand, and your audio and subtitle choice for each show
- My List: a title you add on one device is on the list of every other, and one you remove is
removed from all of them
**What stays on each device:** volume and mute, the streaming cache size, text size, Wi-Fi only, **What stays on each device:** volume and mute, the streaming cache size, text size, Wi-Fi only,
background audio, hardware decoding, window size, and the library's filter and sort order. background audio, hardware decoding, window size, and the library's filter and sort order.
@@ -60,7 +62,7 @@ and when it closes. **Sync now** runs it at once. Android also syncs in the back
after you leave the app. after you leave the app.
**How conflicts are settled:** each device writes only its own file, and reads everyone else's. **How conflicts are settled:** each device writes only its own file, and reads everyone else's.
For each episode and each setting, the newest change wins. A device that signs in for the first For each episode, each setting and each title on My List, the newest change wins. A device that signs in for the first
time takes the others' state and does not overwrite it with its own defaults. time takes the others' state and does not overwrite it with its own defaults.
A device that has not synced for 90 days has its file moved to the Drive trash. A device that has not synced for 90 days has its file moved to the Drive trash.
+14 -3
View File
@@ -14,16 +14,22 @@ The library shows every title from every share in one place. Two shares that hol
show it once. show it once.
- **The banner** across the top shows the title you watched last first, with **Resume**, how far - **The banner** across the top shows the title you watched last first, with **Resume**, how far
into it you are and **More info**. It then turns through up to four more titles that have a wide into it you are, **More info** and **My List**. It then turns through up to four more titles that have a wide
picture, a different set each day, moving on every eight seconds. It holds still while the picture, a different set each day, moving on every eight seconds. It holds still while the
pointer is over it; on a phone, swipe it or tap a dot under it to move on yourself. Its shading pointer is over it; on a phone, swipe it or tap a dot under it to move on yourself. Its shading
takes on the colours of the picture behind it, and so does the top of a title's page. takes on the colours of the picture behind it, and so does the top of a title's page.
- **Continue watching** has the episode you stopped in for each show, or the next one when you - **Continue watching** has the episode you stopped in for each show, or the next one when you
finished an episode. To take a show off this row, right-click it and choose **Remove from finished an episode. To take a show off this row, right-click it and choose **Remove from
Continue watching**. Continue watching**.
- **New episodes** has the shows you are watching that got episodes since you last watched them.
Their tiles say how many, for example **3 new**.
- **My List** has the titles you put aside to watch, the last one added first.
- **Recently added** has the titles that arrived in the last two weeks and that you have not
started. Their tiles say **New**. What was already there when you added a share is not new.
- **Shelves** such as **Airing now**, **Directed by Satoshi Kon** or **From MADHOUSE** appear when - **Shelves** such as **Airing now**, **Directed by Satoshi Kon** or **From MADHOUSE** appear when
posters and descriptions are on. The app shows at most four, and only for a director, studio or posters and descriptions are on. The app shows at most four, and only for a director, studio or
genre with at least three titles. genre with at least three titles. On a shelf, a show that is still airing says when its next
episode is out, for example **Ep 8 · in 2 d**.
- **All titles** is the full grid. Choose a filter and a sort order above it. - **All titles** is the full grid. Choose a filter and a sort order above it.
Rows that run past the window fade out at the edge. Point at a row to show the arrows at its Rows that run past the window fade out at the edge. Point at a row to show the arrows at its
@@ -31,7 +37,12 @@ ends, which move it along by a page.
| Filters | Sort orders | | Filters | Sort orders |
|---|---| |---|---|
| All, Series, Films, Watching, Not started, Downloaded | A – Z, Recently watched, Recently added, Newest, Highest rated, Most popular | | All, My List, Series, Films, Watching, Not started, Downloaded | A – Z, Recently watched, Recently added, Newest, Highest rated, Most popular |
**My List** is a list of titles to watch later. Add a title with **My List** on its page or on
the banner, or right-click its tile and choose **Add to My List**. Choose the button again, or
**Remove from My List**, to take it off. When you are signed in, the list syncs to your other
devices ([Account and sync](ACCOUNT_AND_SYNC.md)).
**Group franchises** folds a series, its sequels and its films into one tile with a **+N** badge. **Group franchises** folds a series, its sequels and its films into one tile with a **+N** badge.
It is on by default, and needs posters and descriptions to know what belongs together. It is on by default, and needs posters and descriptions to know what belongs together.
+15 -1
View File
@@ -480,7 +480,7 @@ final class AppModel {
if report.settingsChanged { if report.settingsChanged {
await AppearanceState.shared.reload() await AppearanceState.shared.reload()
} }
if report.applied > 0 || report.titles > 0 || report.settingsChanged || !report.sharesRemoved.isEmpty { if report.applied > 0 || report.titles > 0 || report.settingsChanged || report.listChanged || !report.sharesRemoved.isEmpty {
await reloadNow() await reloadNow()
} }
// A crawl takes longer than the grace iOS gives a backgrounded app. // A crawl takes longer than the grace iOS gives a backgrounded app.
@@ -534,6 +534,20 @@ final class AppModel {
} }
} }
// MARK: - My List
/// Put a title on My List, or take it off.
func setOnList(_ title: TitleRecord, _ on: Bool) {
Task {
do {
try await run { try $0.setOnList(key: title.key, on: on) }
} catch {
reportError(error)
}
reload()
}
}
// MARK: - Watch state // MARK: - Watch state
/// Every episode of a title watched or unwatched, with an Undo: a whole /// Every episode of a title watched or unwatched, with an Undo: a whole
+1 -1
View File
@@ -78,7 +78,7 @@ struct SettingsStore {
nonmutating set { defaults.set(newValue, forKey: Key.newEpisodeNotifications) } nonmutating set { defaults.set(newValue, forKey: Key.newEpisodeNotifications) }
} }
static let filters: [LibraryFilter] = [.all, .series, .films, .watching, .unwatched, .downloaded] static let filters: [LibraryFilter] = [.all, .myList, .series, .films, .watching, .unwatched, .downloaded]
private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity] private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity]
private func bool(_ key: String, default value: Bool) -> Bool { private func bool(_ key: String, default value: Bool) -> Bool {
+39 -1
View File
@@ -148,6 +148,9 @@ struct LibraryView: View {
onResume(title, nextUpIndex(playlist, continueIndex: title.continueIndex)) onResume(title, nextUpIndex(playlist, continueIndex: title.continueIndex))
} }
MenuRow("info.circle", "Open") { onTitle(title) } MenuRow("info.circle", "Open") { onTitle(title) }
MenuRow(title.onList ? "checkmark" : "plus", title.onList ? "Remove from My List" : "Add to My List") {
model.setOnList(title, !title.onList)
}
MenuRow("arrow.down.circle", "Download all") { pendingDownload = download(playlist, title.displayName) } MenuRow("arrow.down.circle", "Download all") { pendingDownload = download(playlist, title.displayName) }
MenuRow(allWatched ? "checkmark.circle" : "checkmark.circle.fill", allWatched ? "Mark unwatched" : "Mark watched") { MenuRow(allWatched ? "checkmark.circle" : "checkmark.circle.fill", allWatched ? "Mark unwatched" : "Mark watched") {
model.setTitleWatched(title, !allWatched) model.setTitleWatched(title, !allWatched)
@@ -267,7 +270,9 @@ struct LibraryView: View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: 12) { LazyHStack(alignment: .top, spacing: 12) {
ForEach(shelf.titles, id: \.key) { title in ForEach(shelf.titles, id: \.key) { title in
PosterTile(title: title) // On a shelf, an airing show says when its
// next episode is out.
PosterTile(title: title, badge: title.badge ?? title.countdown())
.frame(width: 112) .frame(width: 112)
.tapAndHold { onTitle(title) } onHold: { longPressed { menuFor = title } } .tapAndHold { onTitle(title) } onHold: { longPressed { menuFor = title } }
} }
@@ -479,6 +484,7 @@ extension LibraryFilter {
case .watching: "Watching" case .watching: "Watching"
case .unwatched: "Not started" case .unwatched: "Not started"
case .downloaded: "Downloaded" case .downloaded: "Downloaded"
case .myList: "My List"
} }
} }
} }
@@ -596,6 +602,7 @@ private struct FeaturedPage: View {
let onPlay: (Int) -> Void let onPlay: (Int) -> Void
let onOpen: () -> Void let onOpen: () -> Void
@Environment(\.scheme) private var scheme @Environment(\.scheme) private var scheme
@Environment(AppModel.self) private var model
@State private var mood: UInt32? @State private var mood: UInt32?
var body: some View { var body: some View {
@@ -637,6 +644,13 @@ private struct FeaturedPage: View {
} }
.buttonStyle(.accent) .buttonStyle(.accent)
Button(action: onOpen) { Label("More info", systemImage: "info.circle") }.buttonStyle(.tonal) Button(action: onOpen) { Label("More info", systemImage: "info.circle") }.buttonStyle(.tonal)
// The icon alone: a third labelled button does not fit a
// phone's row.
Button { model.setOnList(title, !title.onList) } label: {
Image(systemName: title.onList ? "checkmark" : "plus")
}
.buttonStyle(.tonal)
.accessibilityLabel(title.onList ? "Remove from My List" : "Add to My List")
} }
.padding(.top, 12) .padding(.top, 12)
if let episode = resume?.episode, episode.resumeAt != nil, let progress = episode.progress { if let episode = resume?.episode, episode.resumeAt != nil, let progress = episode.progress {
@@ -705,8 +719,16 @@ private struct ContinueCard: View {
struct PosterTile: View { struct PosterTile: View {
let title: TitleRecord let title: TitleRecord
var folded = 0 var folded = 0
/// `New`, `3 new`, or an airing show's countdown, at the top left.
var badge: String?
@Environment(\.scheme) private var scheme @Environment(\.scheme) private var scheme
init(title: TitleRecord, folded: Int = 0, badge: String? = nil) {
self.title = title
self.folded = folded
self.badge = badge ?? title.badge
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: title.thumbnailSource) RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: title.thumbnailSource)
@@ -724,6 +746,16 @@ struct PosterTile: View {
.accessibilityLabel("and \(folded) more") .accessibilityLabel("and \(folded) more")
} }
} }
.overlay(alignment: .topLeading) {
if let badge {
Text(badge)
.textStyle(.labelSmall, semibold: true)
.foregroundStyle(scheme.onPrimary)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(scheme.primary, in: RoundedRectangle(cornerRadius: Corner.standard))
.padding(6)
}
}
Text(title.displayName).textStyle(.bodyMedium, semibold: true).foregroundStyle(scheme.onSurface) Text(title.displayName).textStyle(.bodyMedium, semibold: true).foregroundStyle(scheme.onSurface)
.lineLimit(2).padding(.top, 6) .lineLimit(2).padding(.top, 6)
// Two lines, not one: at a large text size "2023 · 7 episodes" // Two lines, not one: at a large text size "2023 · 7 episodes"
@@ -736,6 +768,12 @@ struct PosterTile: View {
} }
extension TitleRecord { extension TitleRecord {
/// `Ep 8 · in 2 d` for a show whose next episode is still to air.
func countdown(now: Int64 = Int64(Date().timeIntervalSince1970)) -> String? {
guard let at = nextAiringAt, at > now, let episode = nextEpisode else { return nil }
return airingCountdown(episode: episode, at: at, now: now)
}
/// "2023 · 11 episodes", or "1988 · Film" — never "1 episodes". /// "2023 · 11 episodes", or "1988 · Film" — never "1 episodes".
var caption: String { var caption: String {
let count: String let count: String
+4
View File
@@ -251,6 +251,10 @@ struct TitleView: View {
.disabled(playlist.isEmpty) .disabled(playlist.isEmpty)
.padding(.top, 16) .padding(.top, 16)
HStack(spacing: 0) { HStack(spacing: 0) {
TitleAction(icon: title.onList ? "checkmark" : "plus", label: "My List") {
model.setOnList(title, !title.onList)
}
.accessibilityValue(title.onList ? "On My List" : "Not on My List")
// Only where it would do something different: an unstarted show // Only where it would do something different: an unstarted show
// already starts at the beginning. // already starts at the beginning.
if upNext?.resumeAt != nil || title.watchedCount > 0 { if upNext?.resumeAt != nil || title.watchedCount > 0 {