feat(core,app,android,ios): My List, synced, and New badges with new-episode rows
My List is a list of titles to watch later, kept in my-list.json by
title key (pstr-core::mylist) and synced as one list/<key> register per
title, so an add or a removal on one device reaches every other. A list
file that will not parse is never written over: changing the list is
refused instead. It is a filter, a row on the library, a button on the
banner and the title page, and an entry in each tile's menu.
pstr-core::news reads node_first_seen against each share's first crawl,
so a newly added share does not badge its whole library. A title that
arrived in the last two weeks and was never played is New; a series
being watched counts the unwatched episodes that arrived after it was
last played ("3 new"). They fill the tile badge and the "New episodes"
and "Recently added" rows. shelves::home puts every row in one order
for all three apps, and a shelf tile of an airing show shows a
countdown ("Ep 8 · in 2 d").
The bridge gains TitleRecord.on_list and .badge, set_on_list, the
MyList filter, airing_countdown and SyncRecord.list_changed.
No files matched your search
@@ -591,7 +591,9 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}
|
||||
}
|
||||
if (report.settingsChanged) AppearanceState.reload()
|
||||
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.sharesRemoved.isNotEmpty()) {
|
||||
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.listChanged ||
|
||||
report.sharesRemoved.isNotEmpty()
|
||||
) {
|
||||
reloadNow()
|
||||
}
|
||||
if (report.sharesAdded.isNotEmpty()) {
|
||||
@@ -625,7 +627,17 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
* title's positions are what this throws away, and a mis-tap on a tile's
|
||||
* menu should not cost them.
|
||||
*/
|
||||
fun setTitleWatched(title: TitleRecord, watched: Boolean) =
|
||||
/** Put a title on My List, or take it off. */
|
||||
fun setOnList(title: TitleRecord, on: Boolean) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().setOnList(title.key, on) }
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
fun setTitleWatched(title: TitleRecord, watched: Boolean) =
|
||||
markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName)
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,13 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -323,3 +330,25 @@ internal fun SpecBadge(text: String) {
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* My List's button: a plus to put the title on it, a tick once it is, which
|
||||
* takes it off again. [compact] is the icon alone, for the banner's row, where
|
||||
* a third labelled button does not fit a phone.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ListButton(onList: Boolean, onSet: (Boolean) -> Unit, compact: Boolean = false) {
|
||||
val icon = if (onList) Icons.Default.Check else Icons.Default.Add
|
||||
val label = if (onList) "Remove from My List" else "Add to My List"
|
||||
if (compact) {
|
||||
FilledTonalIconButton(onClick = { onSet(!onList) }, shape = MaterialTheme.shapes.small) {
|
||||
Icon(icon, contentDescription = label)
|
||||
}
|
||||
} else {
|
||||
TonalButton(onClick = { onSet(!onList) }, modifier = Modifier.semantics { contentDescription = label }) {
|
||||
Icon(icon, contentDescription = null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("My List")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.theme.QuietButton
|
||||
import java.time.LocalDate
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
@@ -116,6 +117,7 @@ import uniffi.pstr_android.LibrarySort
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.TitleType
|
||||
import uniffi.pstr_android.airingCountdown
|
||||
|
||||
/**
|
||||
* How many shows the continue-watching shelf holds.
|
||||
@@ -145,6 +147,7 @@ internal fun LibraryFilter.label(): String = when (this) {
|
||||
LibraryFilter.WATCHING -> "Watching"
|
||||
LibraryFilter.UNWATCHED -> "Not started"
|
||||
LibraryFilter.DOWNLOADED -> "Downloaded"
|
||||
LibraryFilter.MY_LIST -> "My List"
|
||||
}
|
||||
|
||||
/** The offer of posters and synopses, and its two answers. */
|
||||
@@ -190,6 +193,7 @@ internal fun LibraryScreen(
|
||||
onAddShare: () -> Unit = {},
|
||||
onOpenSettings: () -> Unit = {},
|
||||
onFilter: (LibraryFilter) -> Unit = {},
|
||||
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val download = rememberDownload()
|
||||
val context = LocalContext.current
|
||||
@@ -293,6 +297,7 @@ internal fun LibraryScreen(
|
||||
},
|
||||
featured = if (overBanner) featured else emptyList(),
|
||||
grid = grid,
|
||||
onSetOnList = onSetOnList,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -307,6 +312,10 @@ internal fun LibraryScreen(
|
||||
onResume(title, nextUpIndex(playlist, title.continueIndex))
|
||||
}
|
||||
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
|
||||
MenuRow(
|
||||
if (title.onList) Icons.Default.Check else Icons.Default.Add,
|
||||
if (title.onList) "Remove from My List" else "Add to My List",
|
||||
) { onSetOnList(title, !title.onList) }
|
||||
MenuRow(Icons.Default.Download, "Download all") { download(playlist, title.displayName) }
|
||||
MenuRow(
|
||||
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
|
||||
@@ -493,6 +502,7 @@ private fun LibraryGrid(
|
||||
metadataOffer: MetadataOffer? = null,
|
||||
featured: List<TitleRecord> = emptyList(),
|
||||
grid: LazyGridState = rememberLazyGridState(),
|
||||
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
|
||||
) {
|
||||
// The arrangement names titles by key; before it has arrived, the titles
|
||||
// as they came are the grid.
|
||||
@@ -525,6 +535,7 @@ private fun LibraryGrid(
|
||||
resumable,
|
||||
onPlay = onResume,
|
||||
onOpen = onTitle,
|
||||
onSetOnList = onSetOnList,
|
||||
modifier = Modifier.bleed(16.dp),
|
||||
)
|
||||
}
|
||||
@@ -574,6 +585,9 @@ private fun LibraryGrid(
|
||||
onLongClick = { onTitleMenu(title) },
|
||||
modifier = Modifier.width(SHELF_TILE_WIDTH),
|
||||
shared = false,
|
||||
// On a shelf, an airing show says when its
|
||||
// next episode is out.
|
||||
badge = title.badge ?: title.countdown(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -729,6 +743,7 @@ private fun FeaturedBanner(
|
||||
resumable: List<Resumable>,
|
||||
onPlay: (TitleRecord, Int) -> Unit,
|
||||
onOpen: (TitleRecord) -> Unit,
|
||||
onSetOnList: (TitleRecord, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pager = rememberPagerState { titles.size }
|
||||
@@ -750,6 +765,7 @@ private fun FeaturedBanner(
|
||||
resumable.firstOrNull { it.title.key == title.key },
|
||||
onPlay = { onPlay(title, it) },
|
||||
onOpen = { onOpen(title) },
|
||||
onSetOnList = { onSetOnList(title, it) },
|
||||
)
|
||||
}
|
||||
if (titles.size > 1) {
|
||||
@@ -780,7 +796,13 @@ private fun FeaturedBanner(
|
||||
|
||||
/** One title on the banner: its art, its veil, and what it is, from the bottom up. */
|
||||
@Composable
|
||||
private fun FeaturedPage(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) {
|
||||
private fun FeaturedPage(
|
||||
title: TitleRecord,
|
||||
resume: Resumable?,
|
||||
onPlay: (Int) -> Unit,
|
||||
onOpen: () -> Unit,
|
||||
onSetOnList: (Boolean) -> Unit,
|
||||
) {
|
||||
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
||||
var mood by remember(title.key) { mutableStateOf<Color?>(null) }
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
@@ -846,6 +868,7 @@ private fun FeaturedPage(title: TitleRecord, resume: Resumable?, onPlay: (Int) -
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("More info")
|
||||
}
|
||||
ListButton(title.onList, onSetOnList, compact = true)
|
||||
}
|
||||
val episode = resume?.episode
|
||||
val progress = episode?.progress?.takeIf { episode.resumeAt != null }
|
||||
@@ -943,6 +966,7 @@ private fun PosterTile(
|
||||
modifier: Modifier = Modifier,
|
||||
folded: Int = 0,
|
||||
shared: Boolean = true,
|
||||
badge: String? = title.badge,
|
||||
) {
|
||||
Column(
|
||||
modifier
|
||||
@@ -976,6 +1000,20 @@ private fun PosterTile(
|
||||
.semantics { contentDescription = "and $folded more" },
|
||||
)
|
||||
}
|
||||
if (badge != null) {
|
||||
Text(
|
||||
badge,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(6.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
@@ -1017,3 +1055,10 @@ private fun EpisodeRecord.timeLeft(): String? {
|
||||
val minutes = ((at / fraction - at) / 60).toInt()
|
||||
return if (minutes < 1) "under a minute left" else "$minutes min left"
|
||||
}
|
||||
|
||||
/** `Ep 8 · in 2 d` for a show whose next episode is still to air. */
|
||||
internal fun TitleRecord.countdown(now: Long = System.currentTimeMillis() / 1000): String? {
|
||||
val at = nextAiringAt?.takeIf { it > now } ?: return null
|
||||
val episode = nextEpisode ?: return null
|
||||
return airingCountdown(episode, at, now)
|
||||
}
|
||||
@@ -234,6 +234,7 @@ fun ProtonStreamApp(
|
||||
},
|
||||
onOpenSettings = { destination = Destination.Settings },
|
||||
onFilter = model::filter,
|
||||
onSetOnList = model::setOnList,
|
||||
)
|
||||
}
|
||||
val titlePane = @Composable { selected: TitleRecord ->
|
||||
@@ -255,6 +256,7 @@ fun ProtonStreamApp(
|
||||
state.titles.firstOrNull { it.key == key }
|
||||
},
|
||||
onOpenTitle = { selectedTitleKey = it.key },
|
||||
onSetOnList = model::setOnList,
|
||||
)
|
||||
}
|
||||
if (maxWidth >= TWO_PANE_WIDTH) {
|
||||
|
||||
@@ -50,6 +50,9 @@ import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.outlined.CheckCircle
|
||||
import androidx.compose.material.icons.filled.DoneAll
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.RemoveDone
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
@@ -119,6 +122,7 @@ internal fun TitleScreen(
|
||||
/** Every title of its franchise, in release order, this one included. */
|
||||
franchise: List<TitleRecord> = emptyList(),
|
||||
onOpenTitle: (TitleRecord) -> Unit = {},
|
||||
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val download = rememberDownload()
|
||||
@@ -311,6 +315,13 @@ internal fun TitleScreen(
|
||||
// three button styles used to wrap over three lines and bury
|
||||
// the episodes under them.
|
||||
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
TitleAction(
|
||||
if (title.onList) Icons.Default.Check else Icons.Default.Add,
|
||||
"My List",
|
||||
Modifier.weight(1f).semantics {
|
||||
stateDescription = if (title.onList) "On My List" else "Not on My List"
|
||||
},
|
||||
) { onSetOnList(title, !title.onList) }
|
||||
// Only where it would do something different: an unstarted
|
||||
// show already starts at the beginning.
|
||||
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
|
||||
|
||||
|
Before Width: | Height: | Size: 867 KiB After Width: | Height: | Size: 868 KiB |
|
Before Width: | Height: | Size: 826 KiB After Width: | Height: | Size: 827 KiB |
|
Before Width: | Height: | Size: 782 KiB After Width: | Height: | Size: 783 KiB |
|
Before Width: | Height: | Size: 887 KiB After Width: | Height: | Size: 888 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 644 KiB After Width: | Height: | Size: 646 KiB |
|
Before Width: | Height: | Size: 641 KiB After Width: | Height: | Size: 642 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ui::history::Entry>,
|
||||
/// The "Continue watching" shelf, most recent first, as indices.
|
||||
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.
|
||||
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<String, i64>,
|
||||
/// The files kept on this computer, for the Downloaded filter.
|
||||
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.
|
||||
@@ -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<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.
|
||||
fn resumable(library: &Library) -> Vec<usize> {
|
||||
library
|
||||
@@ -628,6 +669,9 @@ pub struct App {
|
||||
pub metadata: HashMap<String, MetadataRecord>,
|
||||
/// When each title last gained a file, by key — for "Recently added".
|
||||
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.
|
||||
pub episodes: HashMap<String, EpisodeGuide>,
|
||||
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,
|
||||
|
||||
@@ -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<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.
|
||||
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<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.
|
||||
track_prefs: Arc<Mutex<HashMap<String, TitleTrackPrefs>>>,
|
||||
downloads: Arc<Mutex<Downloads>>,
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<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 (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<Action>) {
|
||||
fn hero_text(
|
||||
ui: &mut egui::Ui,
|
||||
art: &mut Art<'_>,
|
||||
title: &Title,
|
||||
on_list: bool,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
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<Action>,
|
||||
) {
|
||||
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<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.
|
||||
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<Action>,
|
||||
) {
|
||||
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<Action>,
|
||||
) {
|
||||
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();
|
||||
|
||||
@@ -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<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
|
||||
/// 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 {
|
||||
|
||||
@@ -16,15 +16,23 @@ pub struct OfflineView<'a> {
|
||||
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(
|
||||
ui: &mut egui::Ui,
|
||||
art: &mut Art<'_>,
|
||||
library: &Library,
|
||||
key: &str,
|
||||
franchise: Option<&[String]>,
|
||||
context: Context<'_>,
|
||||
offline: OfflineView<'_>,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
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<Action>,
|
||||
) {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||