mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
cd9e8eea81
commit
f66faf6b75
31 files changed
+1142
-70
No files matched your search
@@ -246,6 +246,9 @@ pub struct SyncRecord {
|
||||
pub settings_changed: bool,
|
||||
/// Hand-picked matches and per-show track choices taken.
|
||||
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)]
|
||||
@@ -357,6 +360,7 @@ pub enum TitleNamesChoice {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
|
||||
pub enum LibraryFilter {
|
||||
All,
|
||||
MyList,
|
||||
Series,
|
||||
Films,
|
||||
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 veils over that picture. All `0xAARRGGBB`; see
|
||||
/// `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]
|
||||
pub fn mood_veil(background: u32, mood: u32, light: bool) -> u32 {
|
||||
let rgb = |argb: u32| {
|
||||
@@ -624,6 +635,12 @@ pub struct TitleRecord {
|
||||
/// shared `TitleDetails::status_label`.
|
||||
#[uniffi(default = None)]
|
||||
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.
|
||||
@@ -1328,6 +1345,7 @@ impl AndroidEngine {
|
||||
shares_removed: profile.shares_removed,
|
||||
settings_changed: profile.settings,
|
||||
titles: u32::try_from(profile.titles).unwrap_or(u32::MAX),
|
||||
list_changed: profile.list,
|
||||
}))
|
||||
}
|
||||
Err(error) if error.is_signed_out() => {
|
||||
@@ -1655,11 +1673,15 @@ impl AndroidEngine {
|
||||
pstr_core::browse::search(&cached.library, &cached.metadata, &query)
|
||||
.into_iter()
|
||||
.filter(|title| {
|
||||
filter.admits(title, |node| {
|
||||
cached
|
||||
.offline
|
||||
.contains(&(node.share_id.clone(), node.link_id.clone()))
|
||||
})
|
||||
filter.admits(
|
||||
title,
|
||||
|node| {
|
||||
cached
|
||||
.offline
|
||||
.contains(&(node.share_id.clone(), node.link_id.clone()))
|
||||
},
|
||||
&cached.list,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let tiles = pstr_core::browse::arrange(
|
||||
@@ -1675,13 +1697,20 @@ impl AndroidEngine {
|
||||
// Shelves are the whole library's, and only when it is shown
|
||||
// whole.
|
||||
let shelves = if query.trim().is_empty() && filter == Filter::All {
|
||||
pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES)
|
||||
.into_iter()
|
||||
.map(|shelf| ShelfRecord {
|
||||
label: shelf.label,
|
||||
keys: shelf.keys,
|
||||
})
|
||||
.collect()
|
||||
pstr_core::shelves::home(
|
||||
&cached.library.titles,
|
||||
&cached.metadata,
|
||||
&cached.list,
|
||||
&cached.first_seen,
|
||||
now(),
|
||||
SHELVES,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|shelf| ShelfRecord {
|
||||
label: shelf.label,
|
||||
keys: shelf.keys,
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
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> {
|
||||
let config = pstr_meta::settings::load(&self.dirs).map_err(BridgeError::from_display)?;
|
||||
let ready = !config.provider.needs_api_key()
|
||||
@@ -2480,11 +2521,15 @@ impl AndroidEngine {
|
||||
let names = pstr_core::appearance::load(&self.dirs)
|
||||
.unwrap_or_default()
|
||||
.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 writes = catalog.writes();
|
||||
if let Some(cached) = self.library_cache.lock().as_ref()
|
||||
&& cached.writes == writes
|
||||
&& cached.names == names
|
||||
&& cached.list == list
|
||||
{
|
||||
return Ok(read(cached));
|
||||
}
|
||||
@@ -2518,6 +2563,7 @@ impl AndroidEngine {
|
||||
})
|
||||
.collect();
|
||||
let now = now();
|
||||
let badges = pstr_core::news::badges(&library.titles, &first_seen, now);
|
||||
let titles: Vec<TitleRecord> = library
|
||||
.titles
|
||||
.iter()
|
||||
@@ -2531,6 +2577,8 @@ impl AndroidEngine {
|
||||
record.display_name =
|
||||
pstr_core::browse::display_name(title, &metadata, names).to_owned();
|
||||
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
|
||||
.get(&title.key)
|
||||
.and_then(|record| record.metadata.as_ref())
|
||||
@@ -2547,6 +2595,7 @@ impl AndroidEngine {
|
||||
let cached = CachedLibrary {
|
||||
writes,
|
||||
names,
|
||||
list,
|
||||
library,
|
||||
metadata,
|
||||
added,
|
||||
@@ -3425,6 +3474,7 @@ fn normalized_language(language: Option<String>) -> Option<String> {
|
||||
struct CachedLibrary {
|
||||
writes: u64,
|
||||
names: TitleNames,
|
||||
list: pstr_core::mylist::MyList,
|
||||
library: Library,
|
||||
metadata: HashMap<String, MetadataRecord>,
|
||||
/// When each title last gained a file.
|
||||
@@ -3535,6 +3585,7 @@ fn filter_choice(filter: LibraryFilter) -> Filter {
|
||||
LibraryFilter::Watching => Filter::Watching,
|
||||
LibraryFilter::Unwatched => Filter::Unwatched,
|
||||
LibraryFilter::Downloaded => Filter::Downloaded,
|
||||
LibraryFilter::MyList => Filter::MyList,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3693,6 +3744,8 @@ fn title_record(
|
||||
.map(str::to_owned),
|
||||
next_episode: None,
|
||||
next_airing_at: None,
|
||||
on_list: false,
|
||||
badge: None,
|
||||
franchise: Vec::new(),
|
||||
logo_url: metadata.and_then(|found| found.logo_url.clone()),
|
||||
runtime: details.and_then(|details| details.runtime),
|
||||
@@ -4345,6 +4398,36 @@ mod tests {
|
||||
let titles = engine.library(Some("frieren".into())).expect("library");
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user