diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index 9d59b8e..c3a5b54 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -591,7 +591,9 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie } } if (report.settingsChanged) AppearanceState.reload() - if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.sharesRemoved.isNotEmpty()) { + if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.listChanged || + report.sharesRemoved.isNotEmpty() + ) { reloadNow() } if (report.sharesAdded.isNotEmpty()) { @@ -625,7 +627,17 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie * title's positions are what this throws away, and a mis-tap on a tile's * menu should not cost them. */ - fun setTitleWatched(title: TitleRecord, watched: Boolean) = + /** Put a title on My List, or take it off. */ + fun setOnList(title: TitleRecord, on: Boolean) { + viewModelScope.launch { + runCatching { + withContext(Dispatchers.IO) { NativeRuntime.engine().setOnList(title.key, on) } + }.onFailure(::reportError) + reload() + } + } + + fun setTitleWatched(title: TitleRecord, watched: Boolean) = markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName) /** diff --git a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt index 3d221fe..0dca4df 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt @@ -18,6 +18,13 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -323,3 +330,25 @@ internal fun SpecBadge(text: String) { .padding(horizontal = 5.dp, vertical = 1.dp), ) } + +/** + * My List's button: a plus to put the title on it, a tick once it is, which + * takes it off again. [compact] is the icon alone, for the banner's row, where + * a third labelled button does not fit a phone. + */ +@Composable +internal fun ListButton(onList: Boolean, onSet: (Boolean) -> Unit, compact: Boolean = false) { + val icon = if (onList) Icons.Default.Check else Icons.Default.Add + val label = if (onList) "Remove from My List" else "Add to My List" + if (compact) { + FilledTonalIconButton(onClick = { onSet(!onList) }, shape = MaterialTheme.shapes.small) { + Icon(icon, contentDescription = label) + } + } else { + TonalButton(onClick = { onSet(!onList) }, modifier = Modifier.semantics { contentDescription = label }) { + Icon(icon, contentDescription = null) + Spacer(Modifier.width(6.dp)) + Text("My List") + } + } +} diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index 556e8e6..5c77767 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -47,6 +47,7 @@ import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.ui.theme.QuietButton import java.time.LocalDate import androidx.compose.foundation.combinedClickable +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Edit @@ -116,6 +117,7 @@ import uniffi.pstr_android.LibrarySort import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleType +import uniffi.pstr_android.airingCountdown /** * How many shows the continue-watching shelf holds. @@ -145,6 +147,7 @@ internal fun LibraryFilter.label(): String = when (this) { LibraryFilter.WATCHING -> "Watching" LibraryFilter.UNWATCHED -> "Not started" LibraryFilter.DOWNLOADED -> "Downloaded" + LibraryFilter.MY_LIST -> "My List" } /** The offer of posters and synopses, and its two answers. */ @@ -190,6 +193,7 @@ internal fun LibraryScreen( onAddShare: () -> Unit = {}, onOpenSettings: () -> Unit = {}, onFilter: (LibraryFilter) -> Unit = {}, + onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> }, ) { val download = rememberDownload() val context = LocalContext.current @@ -293,6 +297,7 @@ internal fun LibraryScreen( }, featured = if (overBanner) featured else emptyList(), grid = grid, + onSetOnList = onSetOnList, ) } } @@ -307,6 +312,10 @@ internal fun LibraryScreen( onResume(title, nextUpIndex(playlist, title.continueIndex)) } MenuRow(Icons.Default.Info, "Open") { onTitle(title) } + MenuRow( + if (title.onList) Icons.Default.Check else Icons.Default.Add, + if (title.onList) "Remove from My List" else "Add to My List", + ) { onSetOnList(title, !title.onList) } MenuRow(Icons.Default.Download, "Download all") { download(playlist, title.displayName) } MenuRow( if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle, @@ -493,6 +502,7 @@ private fun LibraryGrid( metadataOffer: MetadataOffer? = null, featured: List = emptyList(), grid: LazyGridState = rememberLazyGridState(), + onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> }, ) { // The arrangement names titles by key; before it has arrived, the titles // as they came are the grid. @@ -525,6 +535,7 @@ private fun LibraryGrid( resumable, onPlay = onResume, onOpen = onTitle, + onSetOnList = onSetOnList, modifier = Modifier.bleed(16.dp), ) } @@ -574,6 +585,9 @@ private fun LibraryGrid( onLongClick = { onTitleMenu(title) }, modifier = Modifier.width(SHELF_TILE_WIDTH), shared = false, + // On a shelf, an airing show says when its + // next episode is out. + badge = title.badge ?: title.countdown(), ) } } @@ -729,6 +743,7 @@ private fun FeaturedBanner( resumable: List, onPlay: (TitleRecord, Int) -> Unit, onOpen: (TitleRecord) -> Unit, + onSetOnList: (TitleRecord, Boolean) -> Unit, modifier: Modifier = Modifier, ) { val pager = rememberPagerState { titles.size } @@ -750,6 +765,7 @@ private fun FeaturedBanner( resumable.firstOrNull { it.title.key == title.key }, onPlay = { onPlay(title, it) }, onOpen = { onOpen(title) }, + onSetOnList = { onSetOnList(title, it) }, ) } if (titles.size > 1) { @@ -780,7 +796,13 @@ private fun FeaturedBanner( /** One title on the banner: its art, its veil, and what it is, from the bottom up. */ @Composable -private fun FeaturedPage(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) { +private fun FeaturedPage( + title: TitleRecord, + resume: Resumable?, + onPlay: (Int) -> Unit, + onOpen: () -> Unit, + onSetOnList: (Boolean) -> Unit, +) { val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) } var mood by remember(title.key) { mutableStateOf(null) } val background = MaterialTheme.colorScheme.background @@ -846,6 +868,7 @@ private fun FeaturedPage(title: TitleRecord, resume: Resumable?, onPlay: (Int) - Spacer(Modifier.width(6.dp)) Text("More info") } + ListButton(title.onList, onSetOnList, compact = true) } val episode = resume?.episode val progress = episode?.progress?.takeIf { episode.resumeAt != null } @@ -943,6 +966,7 @@ private fun PosterTile( modifier: Modifier = Modifier, folded: Int = 0, shared: Boolean = true, + badge: String? = title.badge, ) { Column( modifier @@ -976,6 +1000,20 @@ private fun PosterTile( .semantics { contentDescription = "and $folded more" }, ) } + if (badge != null) { + Text( + badge, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .align(Alignment.TopStart) + .padding(6.dp) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.primary) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } } Spacer(Modifier.height(6.dp)) Text( @@ -1017,3 +1055,10 @@ private fun EpisodeRecord.timeLeft(): String? { val minutes = ((at / fraction - at) / 60).toInt() return if (minutes < 1) "under a minute left" else "$minutes min left" } + +/** `Ep 8 · in 2 d` for a show whose next episode is still to air. */ +internal fun TitleRecord.countdown(now: Long = System.currentTimeMillis() / 1000): String? { + val at = nextAiringAt?.takeIf { it > now } ?: return null + val episode = nextEpisode ?: return null + return airingCountdown(episode, at, now) +} diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index e49c74b..4d7a8dc 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -234,6 +234,7 @@ fun ProtonStreamApp( }, onOpenSettings = { destination = Destination.Settings }, onFilter = model::filter, + onSetOnList = model::setOnList, ) } val titlePane = @Composable { selected: TitleRecord -> @@ -255,6 +256,7 @@ fun ProtonStreamApp( state.titles.firstOrNull { it.key == key } }, onOpenTitle = { selectedTitleKey = it.key }, + onSetOnList = model::setOnList, ) } if (maxWidth >= TWO_PANE_WIDTH) { diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index 972889e..dad0e1d 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -50,6 +50,9 @@ import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.Download +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.RemoveDone import androidx.compose.material.icons.filled.Pause @@ -119,6 +122,7 @@ internal fun TitleScreen( /** Every title of its franchise, in release order, this one included. */ franchise: List = emptyList(), onOpenTitle: (TitleRecord) -> Unit = {}, + onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> }, ) { val context = LocalContext.current val download = rememberDownload() @@ -311,6 +315,13 @@ internal fun TitleScreen( // three button styles used to wrap over three lines and bury // the episodes under them. Row(Modifier.fillMaxWidth().padding(top = 12.dp)) { + TitleAction( + if (title.onList) Icons.Default.Check else Icons.Default.Add, + "My List", + Modifier.weight(1f).semantics { + stateDescription = if (title.onList) "On My List" else "Not on My List" + }, + ) { onSetOnList(title, !title.onList) } // Only where it would do something different: an unstarted // show already starts at the beginning. if (upNext?.resumeAt != null || title.watchedCount > 0uL) { diff --git a/android/app/src/test/screenshots/library-phone-large-font.png b/android/app/src/test/screenshots/library-phone-large-font.png index 7857e7a..1c72730 100644 Binary files a/android/app/src/test/screenshots/library-phone-large-font.png and b/android/app/src/test/screenshots/library-phone-large-font.png differ diff --git a/android/app/src/test/screenshots/library-phone-light.png b/android/app/src/test/screenshots/library-phone-light.png index 0e13daf..d2f2448 100644 Binary files a/android/app/src/test/screenshots/library-phone-light.png and b/android/app/src/test/screenshots/library-phone-light.png differ diff --git a/android/app/src/test/screenshots/library-phone-offer.png b/android/app/src/test/screenshots/library-phone-offer.png index c33acac..3d2f1ac 100644 Binary files a/android/app/src/test/screenshots/library-phone-offer.png and b/android/app/src/test/screenshots/library-phone-offer.png differ diff --git a/android/app/src/test/screenshots/library-phone.png b/android/app/src/test/screenshots/library-phone.png index b4df4c5..b439be4 100644 Binary files a/android/app/src/test/screenshots/library-phone.png and b/android/app/src/test/screenshots/library-phone.png differ diff --git a/android/app/src/test/screenshots/library-tablet.png b/android/app/src/test/screenshots/library-tablet.png index 6f1b516..1efdd41 100644 Binary files a/android/app/src/test/screenshots/library-tablet.png and b/android/app/src/test/screenshots/library-tablet.png differ diff --git a/android/app/src/test/screenshots/title-phone-light.png b/android/app/src/test/screenshots/title-phone-light.png index 86e74af..0d09783 100644 Binary files a/android/app/src/test/screenshots/title-phone-light.png and b/android/app/src/test/screenshots/title-phone-light.png differ diff --git a/android/app/src/test/screenshots/title-phone.png b/android/app/src/test/screenshots/title-phone.png index 7118b4b..5871ea4 100644 Binary files a/android/app/src/test/screenshots/title-phone.png and b/android/app/src/test/screenshots/title-phone.png differ diff --git a/android/app/src/test/screenshots/title-tablet.png b/android/app/src/test/screenshots/title-tablet.png index 7e101e6..1f38fa3 100644 Binary files a/android/app/src/test/screenshots/title-tablet.png and b/android/app/src/test/screenshots/title-tablet.png differ diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index 96433a6..44ab33e 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -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) -> Option { /// 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, + /// 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, } /// 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 { 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 = 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) -> Option { struct CachedLibrary { writes: u64, names: TitleNames, + list: pstr_core::mylist::MyList, library: Library, metadata: HashMap, /// 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); } diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 00434b7..8e06ff8 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -13,6 +13,7 @@ use pstr_core::appearance::Appearance; use pstr_core::config::AppDirs; use pstr_core::library::Library; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames}; +use pstr_core::mylist::MyList; use crate::engine::{ AccountStatus, DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures, @@ -293,6 +294,11 @@ pub enum Action { SetShelf(Filter, Sort), /// Fold each franchise into one tile, or show every title. 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. ToggleShortcuts, /// One page back: a title to the library, the library to nowhere. @@ -410,6 +416,10 @@ pub struct LibraryView { pub history: Vec, /// The "Continue watching" shelf, most recent first, as indices. pub resumable: Vec, + /// What each tile says about what arrived — `New`, `3 new` — by key. + pub badges: HashMap, + /// The keys on My List. + pub listed: std::collections::HashSet, /// The library or what is known about it changed under the lists. stale: bool, /// Only watch state changed: the shelves and franchises still stand, @@ -445,6 +455,8 @@ impl Default for LibraryView { shelves: Vec::new(), history: Vec::new(), resumable: Vec::new(), + badges: HashMap::new(), + listed: std::collections::HashSet::new(), stale: false, progressed: false, } @@ -462,6 +474,10 @@ pub struct ViewInputs<'a> { pub added: &'a HashMap, /// The files kept on this computer, for the Downloaded filter. pub offline: &'a std::collections::HashSet, + 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. @@ -481,6 +497,7 @@ impl LibraryView { if !self.stale && self.progressed && unchanged && !watch_shapes_grid { self.history = ui::history::entries(library); self.resumable = Self::resumable(library); + self.badges = Self::badges(library, &inputs); self.progressed = false; return; } @@ -508,28 +525,44 @@ impl LibraryView { .enumerate() .map(|(at, title)| (title.key.as_str(), at)) .collect(); - self.shelves = pstr_core::shelves::pick(&library.titles, metadata, SHELVES) - .into_iter() - .map(|shelf| { - let indices = shelf - .keys - .iter() - .filter_map(|key| index.get(key.as_str()).copied()) - .collect(); - (shelf.label, indices) - }) + self.listed = inputs + .list + .entries() + .map(|(key, _)| key.to_owned()) .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) .into_iter() .filter(|title| { - self.filter.admits(title, |node| { - inputs.offline.contains(&DownloadKey { - share_id: node.share_id.clone(), - link_id: node.link_id.clone(), - }) - }) + self.filter.admits( + title, + |node| { + inputs.offline.contains(&DownloadKey { + share_id: node.share_id.clone(), + link_id: node.link_id.clone(), + }) + }, + inputs.list, + ) }) .collect(); let tiles = pstr_core::browse::arrange( @@ -564,12 +597,20 @@ impl LibraryView { if self.stale || self.progressed { self.history = ui::history::entries(library); self.resumable = Self::resumable(library); + self.badges = Self::badges(library, &inputs); } self.query = inputs.query.to_owned(); self.stale = false; self.progressed = false; } + fn badges(library: &Library, inputs: &ViewInputs<'_>) -> HashMap { + 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. fn resumable(library: &Library) -> Vec { library @@ -628,6 +669,9 @@ pub struct App { pub metadata: HashMap, /// When each title last gained a file, by key — for "Recently added". pub added: HashMap, + /// 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. pub episodes: HashMap, pub settings: MetadataConfig, @@ -723,6 +767,7 @@ impl App { let window = crate::window::WindowMemory::new(dirs.window_file()); let notifier = crate::notify::Notifier::new(&dirs.cache); let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?; + let my_list = engine.my_list(); theme::install_fonts(&cc.egui_ctx); theme::apply(&cc.egui_ctx, engine.appearance()); cc.egui_ctx.set_zoom_factor(engine.desktop_prefs().zoom()); @@ -752,6 +797,8 @@ impl App { posters: ImageCache::default(), metadata: HashMap::new(), added: HashMap::new(), + first_seen: HashMap::new(), + my_list, episodes: HashMap::new(), settings, api_key: String::new(), @@ -886,6 +933,11 @@ impl App { self.connecting = false; 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) => { self.library = library; self.added = added; @@ -1685,6 +1737,10 @@ impl App { self.view.grouped = grouped; 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 { Page::Title(_) => self.page = Page::Library, // 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, added: &self.added, 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, library, 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 { downloads, files: offline_files, diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index 1dd0220..4755163 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -33,6 +33,7 @@ use pstr_core::catalog::{ use pstr_core::config::AppDirs; use pstr_core::library::{Library, Title, TitleKind}; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId}; +use pstr_core::mylist::MyList; use pstr_core::prefs::PlaybackPrefs; use pstr_core::profile::ProfileChanges; 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 /// gained a file. LibraryLoaded(Library, HashMap), + /// 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. Crawled { share_id: String, @@ -415,6 +422,9 @@ pub struct Engine { /// 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. appearance: Arc>, + /// 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>>, /// Preloaded so clicking Play never runs SQLite on egui's thread. track_prefs: Arc>>, downloads: Arc>, @@ -522,6 +532,10 @@ impl Engine { 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 // 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 @@ -560,6 +574,7 @@ impl Engine { enrichment_settled: Arc::new(tokio::sync::watch::channel(!deferred).0), prefs: Arc::new(Mutex::new(prefs)), appearance: Arc::new(Mutex::new(appearance)), + my_list: Arc::new(Mutex::new(my_list)), track_prefs: Arc::new(Mutex::new(track_prefs)), downloads: Arc::new(Mutex::new(Downloads::default())), download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)), @@ -624,6 +639,43 @@ impl Engine { *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. /// /// 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}"), } } + 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 { match self.catalog.lock().all_title_track_prefs() { Ok(prefs) => *self.track_prefs.lock() = prefs, @@ -1289,6 +1350,7 @@ impl Engine { let library = Library::build(files, &watch); let targets = download_targets(&library); let added = library.added_at(&first_seen); + engine.emit(Event::FirstSeen(first_seen)); engine.emit(Event::LibraryLoaded(library, added)); engine.refresh_offline(Some(targets)); } diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index 0507f6d..be30464 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -1,5 +1,7 @@ //! The library page: what to watch, as a wall of stills. +use std::collections::HashSet; + use pstr_core::appearance::Tiles; 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]) .show(ui, |ui| { if !featured.is_empty() { - hero(ui, art, &featured, actions); + hero(ui, art, &featured, &view.listed, actions); ui.add_space(theme::space::L); } 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); if !resumable.is_empty() { ui::section(ui, "Continue watching"); - continue_row(ui, art, &resumable, actions); + continue_row(ui, art, &resumable, view, actions); ui.add_space(theme::space::XL); } // 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 { for (label, indices) in &view.shelves { 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); } } @@ -201,7 +203,13 @@ struct Turn { /// /// 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. -fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec) { +fn hero( + ui: &mut egui::Ui, + art: &mut Art<'_>, + titles: &[&Title], + listed: &HashSet, + actions: &mut Vec, +) { let height = (ui.ctx().content_rect().height() * 0.5).clamp(280.0, 520.0); let (column, _) = ui.allocate_exact_size( 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)), |ui| { 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 /// 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) { +fn hero_text( + ui: &mut egui::Ui, + art: &mut Art<'_>, + title: &Title, + on_list: bool, + actions: &mut Vec, +) { let found = art .metadata .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() { 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); if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) { @@ -614,10 +630,12 @@ fn shelf_row( art: &mut Art<'_>, label: &str, titles: &[&Title], + view: &LibraryView, tiles: Tiles, actions: &mut Vec, ) { let (width, aspect) = tile_shape(tiles); + let now = crate::engine::unix_now(); ui::shelf(ui, ("shelf", label), |ui| { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = theme::CARD_GAP; @@ -633,12 +651,16 @@ fn shelf_row( name: art.name(title), subtitle: subtitle(title), 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, aspect, }, ); - tile_menu(&response, title, false, actions); + tile_menu(&response, title, false, view, actions); if response.clicked() { 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 { + 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. fn tile_shape(tiles: Tiles) -> (f32, f32) { match tiles { @@ -660,6 +695,7 @@ fn continue_row( ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], + view: &LibraryView, actions: &mut Vec, ) { ui::shelf(ui, "continue", |ui| { @@ -693,7 +729,7 @@ fn continue_row( aspect: theme::CARD_ASPECT, }, ); - tile_menu(&response, title, true, actions); + tile_menu(&response, title, true, view, actions); if response.clicked() { 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), // What a film is says itself in the subtitle below the // card; a second `Film` over the poster is noise. A - // folded franchise says how much is under it. - badge: folded.map(|more| format!("+{more}")), + // folded franchise says how much is under it, unless + // something new arrived in it. + badge: view + .badges + .get(&title.key) + .cloned() + .or_else(|| folded.map(|more| format!("+{more}"))), width: grid.width, aspect, }, ); - tile_menu(&response, title, false, actions); + tile_menu(&response, title, false, view, actions); if response.clicked() { actions.push(Action::Goto(Page::Title(title.key.clone()))); @@ -783,6 +824,7 @@ fn tile_menu( response: &egui::Response, title: &Title, continuing: bool, + view: &LibraryView, actions: &mut Vec, ) { response.context_menu(|ui| { @@ -799,6 +841,20 @@ fn tile_menu( if ui.button("Open").clicked() { 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(); let all_watched = title.watched_count() == title.episode_count(); diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index 6c72e09..de8a54c 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -23,6 +23,7 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2}; use pstr_core::library::{Episode, Title}; 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::theme; 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)) } +/// 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) { + 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 /// 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 { diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs index f31d1f8..86cd54d 100644 --- a/crates/pstr-app/src/ui/title.rs +++ b/crates/pstr-app/src/ui/title.rs @@ -16,15 +16,23 @@ pub struct OfflineView<'a> { pub files: &'a std::collections::HashSet, } +/// 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( ui: &mut egui::Ui, art: &mut Art<'_>, library: &Library, key: &str, - franchise: Option<&[String]>, + context: Context<'_>, offline: OfflineView<'_>, actions: &mut Vec, ) { + let Context { franchise, on_list } = context; let Some(title) = library.get(key) else { // 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. @@ -40,7 +48,7 @@ pub fn show( egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { - header(ui, art, title, &offline, actions); + header(ui, art, title, on_list, &offline, actions); ui.add_space(theme::space::XL); if let Some(keys) = franchise { @@ -289,6 +297,7 @@ fn header( ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, + on_list: bool, offline: &OfflineView<'_>, actions: &mut Vec, ) { @@ -425,6 +434,7 @@ fn header( None, ))); } + ui::list_button(ui, &title.key, on_list, actions); let title_keys: Vec<_> = title.episodes().map(key_of).collect(); let all_offline = title_keys.iter().all(|key| offline.files.contains(key)); if all_offline { diff --git a/crates/pstr-core/src/browse.rs b/crates/pstr-core/src/browse.rs index cec51ab..51bc421 100644 --- a/crates/pstr-core/src/browse.rs +++ b/crates/pstr-core/src/browse.rs @@ -10,6 +10,7 @@ use std::collections::{HashMap, HashSet}; use crate::catalog::CatalogNode; use crate::library::{Library, Title, TitleKind, sort_name}; use crate::metadata::{MetadataRecord, TitleNames}; +use crate::mylist::MyList; /// What order a grid is in. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] @@ -66,11 +67,14 @@ pub enum Filter { Unwatched, /// With at least one episode kept on this device. Downloaded, + /// On My List. + MyList, } impl Filter { - pub const ALL: [Self; 6] = [ + pub const ALL: [Self; 7] = [ Self::All, + Self::MyList, Self::Series, Self::Films, Self::Watching, @@ -86,12 +90,18 @@ impl Filter { Self::Watching => "Watching", Self::Unwatched => "Not started", Self::Downloaded => "Downloaded", + Self::MyList => "My List", } } /// Whether the grid shows `title`. `offline` says whether a file is kept /// 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(); match self { Self::All => true, @@ -102,6 +112,7 @@ impl Filter { } Self::Unwatched => watched == 0 && title.resume().is_none(), 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 series = title("s", "Series", TitleKind::Series); let none = |_: &CatalogNode| false; - assert!(Filter::All.admits(&film, none) && Filter::All.admits(&series, none)); - assert!(Filter::Films.admits(&film, none) && !Filter::Films.admits(&series, none)); - assert!(Filter::Series.admits(&series, none) && !Filter::Series.admits(&film, none)); + let list = MyList::default(); + assert!(Filter::All.admits(&film, none, &list) && Filter::All.admits(&series, none, &list)); + 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. - assert!(Filter::Unwatched.admits(&film, none)); - assert!(!Filter::Watching.admits(&film, none)); + assert!(Filter::Unwatched.admits(&film, none, &list)); + assert!(!Filter::Watching.admits(&film, none, &list)); } #[test] @@ -486,7 +503,18 @@ mod tests { let library = Library::build(vec![node("a", 1), node("b", 2)], &HashMap::new()); let show = &library.titles[0]; - assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b")); - assert!(!Filter::Downloaded.admits(show, |_| false)); + let list = MyList::default(); + 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)); } } diff --git a/crates/pstr-core/src/lib.rs b/crates/pstr-core/src/lib.rs index 465ff68..f0f77ed 100644 --- a/crates/pstr-core/src/lib.rs +++ b/crates/pstr-core/src/lib.rs @@ -16,7 +16,9 @@ pub mod franchise; pub mod language; pub mod library; pub mod metadata; +pub mod mylist; pub mod naming; +pub mod news; pub mod prefs; pub mod profile; pub mod shares; diff --git a/crates/pstr-core/src/mylist.rs b/crates/pstr-core/src/mylist.rs new file mode 100644 index 0000000..51a8d16 --- /dev/null +++ b/crates/pstr-core/src/mylist.rs @@ -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/` 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); + } +} diff --git a/crates/pstr-core/src/news.rs b/crates/pstr-core/src/news.rs new file mode 100644 index 0000000..9f63967 --- /dev/null +++ b/crates/pstr-core/src/news.rs @@ -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"); + } +} diff --git a/crates/pstr-core/src/profile.rs b/crates/pstr-core/src/profile.rs index 6672555..848eb78 100644 --- a/crates/pstr-core/src/profile.rs +++ b/crates/pstr-core/src/profile.rs @@ -1,6 +1,6 @@ //! 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 -//! choices. Synced next to watch history by [`crate::sync`], in the same file. +//! the settings, the hand-picked metadata matches, the per-show track choices +//! and My List. Synced next to watch history by [`crate::sync`], in the same file. //! //! ## Registers //! @@ -8,8 +8,8 @@ //! `settings/playback`, `tracks/<title>`, …), its latest value, and when it //! 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 -//! number of devices. A removed share is a register with no value, so a -//! removal travels like any other change. +//! number of devices. A removed share, or a title taken off My List, is a +//! register with no value, so a removal travels like any other change. //! //! ## Noticing changes //! @@ -56,6 +56,7 @@ use crate::shares::{AccountFolder, Share, ShareSecrets, ShareStore}; const SHARE: &str = "share/"; const TRACKS: &str = "tracks/"; const MATCH: &str = "match/"; +const LIST: &str = "list/"; /// The settings files that travel, by register, and the fields in each that /// stay on the device that wrote them. @@ -98,6 +99,12 @@ struct SyncedShare { 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 /// device with enrichment on finds them again, and they expire anyway. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -123,6 +130,8 @@ pub struct ProfileChanges { pub settings: bool, /// Hand-picked matches and per-show track choices taken. pub titles: usize, + /// Whether My List changed. + pub list: bool, } impl ProfileChanges { @@ -154,6 +163,8 @@ struct Observed { /// Whether the share list itself could be read. Without it, a share /// missing from `values` says nothing about whether it was removed. shares_listed: bool, + /// The same for My List. + list_read: bool, } 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(); if let Ok(tracks) = catalog.all_title_track_prefs() { for (title, prefs) in tracks { @@ -320,6 +343,19 @@ impl Profile<'_> { } 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 { match value.and_then(Value::as_str) { 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`. fn stamp(registers: &mut Registers, observed: Observed, now: i64) { 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() - && key.starts_with(SHARE) - && observed.shares_listed + && removable && !observed.values.contains_key(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] fn a_synced_share_that_names_another_id_is_refused() { let phone = Device::new("phone"); diff --git a/crates/pstr-core/src/shelves.rs b/crates/pstr-core/src/shelves.rs index ca86ab6..e2f9946 100644 --- a/crates/pstr-core/src/shelves.rs +++ b/crates/pstr-core/src/shelves.rs @@ -135,6 +135,38 @@ pub fn pick( 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)] mod tests { use super::*; @@ -259,4 +291,17 @@ mod tests { assert_eq!(shelves[0].label, "Airing now"); 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"]); + } } diff --git a/docs/ACCOUNT_AND_SYNC.md b/docs/ACCOUNT_AND_SYNC.md index 827a4ef..7750c6d 100644 --- a/docs/ACCOUNT_AND_SYNC.md +++ b/docs/ACCOUNT_AND_SYNC.md @@ -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: - **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 - you sign in on. +- **Sync** of your place in every episode, your shares, your settings and My List, across every + device you sign in on. ## 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) - 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 +- 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, 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. **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. A device that has not synced for 90 days has its file moved to the Drive trash. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 7075854..ed0f6f5 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -14,16 +14,22 @@ The library shows every title from every share in one place. Two shares that hol show it once. - **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 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. - **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 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 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. 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 | |---|---| -| 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. It is on by default, and needs posters and descriptions to know what belongs together. diff --git a/ios/ProtonStream/App/AppModel.swift b/ios/ProtonStream/App/AppModel.swift index ffbb25e..e2a8f91 100644 --- a/ios/ProtonStream/App/AppModel.swift +++ b/ios/ProtonStream/App/AppModel.swift @@ -480,7 +480,7 @@ final class AppModel { if report.settingsChanged { 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() } // 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 /// Every episode of a title watched or unwatched, with an Undo: a whole diff --git a/ios/ProtonStream/App/SettingsStore.swift b/ios/ProtonStream/App/SettingsStore.swift index 5a51741..7f833bd 100644 --- a/ios/ProtonStream/App/SettingsStore.swift +++ b/ios/ProtonStream/App/SettingsStore.swift @@ -78,7 +78,7 @@ struct SettingsStore { 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 func bool(_ key: String, default value: Bool) -> Bool { diff --git a/ios/ProtonStream/UI/LibraryView.swift b/ios/ProtonStream/UI/LibraryView.swift index 0fc39a7..0c35642 100644 --- a/ios/ProtonStream/UI/LibraryView.swift +++ b/ios/ProtonStream/UI/LibraryView.swift @@ -148,6 +148,9 @@ struct LibraryView: View { onResume(title, nextUpIndex(playlist, continueIndex: title.continueIndex)) } 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(allWatched ? "checkmark.circle" : "checkmark.circle.fill", allWatched ? "Mark unwatched" : "Mark watched") { model.setTitleWatched(title, !allWatched) @@ -267,7 +270,9 @@ struct LibraryView: View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack(alignment: .top, spacing: 12) { 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) .tapAndHold { onTitle(title) } onHold: { longPressed { menuFor = title } } } @@ -479,6 +484,7 @@ extension LibraryFilter { case .watching: "Watching" case .unwatched: "Not started" case .downloaded: "Downloaded" + case .myList: "My List" } } } @@ -596,6 +602,7 @@ private struct FeaturedPage: View { let onPlay: (Int) -> Void let onOpen: () -> Void @Environment(\.scheme) private var scheme + @Environment(AppModel.self) private var model @State private var mood: UInt32? var body: some View { @@ -637,6 +644,13 @@ private struct FeaturedPage: View { } .buttonStyle(.accent) 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) if let episode = resume?.episode, episode.resumeAt != nil, let progress = episode.progress { @@ -705,8 +719,16 @@ private struct ContinueCard: View { struct PosterTile: View { let title: TitleRecord var folded = 0 + /// `New`, `3 new`, or an airing show's countdown, at the top left. + var badge: String? @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 { VStack(alignment: .leading, spacing: 0) { RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: title.thumbnailSource) @@ -724,6 +746,16 @@ struct PosterTile: View { .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) .lineLimit(2).padding(.top, 6) // Two lines, not one: at a large text size "2023 · 7 episodes" @@ -736,6 +768,12 @@ struct PosterTile: View { } 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". var caption: String { let count: String diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index 5754302..2ebacbb 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -251,6 +251,10 @@ struct TitleView: View { .disabled(playlist.isEmpty) .padding(.top, 16) 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 // already starts at the beginning. if upNext?.resumeAt != nil || title.watchedCount > 0 {