Compare commits

3 Commits
Author SHA1 Message Date
nvrl ce02bbc35a fix(vers) updated cargo + pkgbuild version 2026-10-12 19:10:58 +02:00
nvrl 0b9a9dab50 fix(ios,android): offer the right episode as up next while the next one opens
After "next episode" during the credits, the previous file keeps
playing until the new stream is open. Its chapters and position were
read against the already-advanced index, so the up-next card offered
the episode after the next one for those seconds. The card, the skip
offer and auto-skip now only act on readings of the open episode.
2026-10-12 18:36:33 +02:00
nvrl 73973841c2 feat(core,app,android,ios,cli): look up new titles after every crawl, show presses and work on iOS
Metadata was only fetched after adding a share (mobile) or from the
Settings buttons (desktop), so a show or episode that turned up in an
existing share stayed bare. Every crawl is now followed by a match run
when enrichment is on; `plan` makes no requests for a library that is
already matched. A stored episode guide is fetched again when a file
newer than it has no named entry, and daily for two weeks while an
aired episode is still unnamed.

Removing a share now carries its watch state to any other share holding
the same file, `pstr remove` drops the share's catalog rows, and the app
forgets catalog rows of shares no longer configured — an older `pstr
remove` left them, and every episode showed twice.

iOS, for a slow iPad:
- swipe back on a settings page uses its own edge strip; on the whole
  page it also pressed the row under the finger and opened the metadata
  sheet
- "Change match" from the tile menu opens once the menu has dismissed,
  not after a guessed 350 ms that a slow device missed
- the activity banner tracks each running line by itself (also Android),
  so overlapping work no longer leaves it spinning forever
- buttons, tiles and rows draw their press; marking watched shows at
  once; remove/repair share, sign out and deletions show the banner;
  the player says "Opening…" while the stream opens
- posters are read into a reserved buffer instead of a byte at a time
2026-10-12 18:36:33 +02:00
23 changed files with 717 additions and 127 deletions

No files matched your search

Generated
+7 -7
View File
@@ -5122,7 +5122,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-android" name = "pstr-android"
version = "1.4.0" version = "1.4.1"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"futures", "futures",
@@ -5142,7 +5142,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-app" name = "pstr-app"
version = "1.4.0" version = "1.4.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -5176,7 +5176,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-cli" name = "pstr-cli"
version = "1.4.0" version = "1.4.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -5195,7 +5195,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-core" name = "pstr-core"
version = "1.4.0" version = "1.4.1"
dependencies = [ dependencies = [
"directories", "directories",
"futures", "futures",
@@ -5215,7 +5215,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-meta" name = "pstr-meta"
version = "1.4.0" version = "1.4.1"
dependencies = [ dependencies = [
"pstr-core", "pstr-core",
"reqwest", "reqwest",
@@ -5228,7 +5228,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-player" name = "pstr-player"
version = "1.4.0" version = "1.4.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"libloading", "libloading",
@@ -5243,7 +5243,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-stream" name = "pstr-stream"
version = "1.4.0" version = "1.4.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"futures", "futures",
+1 -1
View File
@@ -11,7 +11,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.4.0" version = "1.4.1"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://github.com/narrrl/proton-stream" repository = "https://github.com/narrrl/proton-stream"
@@ -333,7 +333,12 @@ fun PlayerScreen(
val problem by nativeHost.problem.collectAsState() val problem by nativeHost.problem.collectAsState()
LaunchedEffect(problem) { problem?.let { playbackError = it } } LaunchedEffect(problem) { problem?.let { playbackError = it } }
val offer = remember(chapters, key, (state.position * 4).roundToInt()) { // Whether mpv's readings are this episode's. Not while the next one opens:
// the last one's credits play on until it is loaded, and read against the
// new index they offered the episode after the next as up next.
val playingThis = state.media == key
val offer = remember(chapters, key, playingThis, (state.position * 4).roundToInt()) {
if (!playingThis) return@remember null
runCatching { skipOffer(chapters, state.position) }.getOrNull() runCatching { skipOffer(chapters, state.position) }.getOrNull()
} }
// Auto-skip is offered once per chapter: seeking back into an opening on // Auto-skip is offered once per chapter: seeking back into an opening on
@@ -475,7 +480,7 @@ fun PlayerScreen(
UpNextCard( UpNextCard(
next = next, next = next,
visible = autoplay && !state.paused && visible = autoplay && !state.paused && playingThis &&
chapters.creditsStart?.let { state.position >= it } == true, chapters.creditsStart?.let { state.position >= it } == true,
onPlayNow = { advance() }, onPlayNow = { advance() },
modifier = Modifier modifier = Modifier
@@ -110,6 +110,13 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
/** Launch and coming back both ask; one crawl answers. */ /** Launch and coming back both ask; one crawl answers. */
private var catchingUp = false private var catchingUp = false
/**
* Every banner line whose work is still running, oldest first. Touched on
* the main thread only, as everything in [viewModelScope] is.
*/
private val activities = mutableListOf<Pair<Long, String>>()
private var nextActivity = 0L
init { init {
if (!NativeRuntime.tlsReady) { if (!NativeRuntime.tlsReady) {
mutableState.update { mutableState.update {
@@ -163,24 +170,46 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } } runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
} }
reload() reloadNow()
matchAfterCrawl()
} }
} }
/** /**
* Say [what] in the activity banner while [work] runs. Nested work keeps * Look up whatever a crawl brought that has no answer yet: a new show, or
* the outer line once the inner one is done. * a new episode its stored guide cannot name. When everything already has
* one the bridge asks nothing, so this is cheap after every crawl.
*/
private suspend fun matchAfterCrawl() {
val settings = mutableState.value.metadataSettings
if (settings.enabled && settings.ready) match(force = false, quiet = true)
}
/**
* Say [what] in the activity banner while [work] runs.
*
* The banner shows the newest line still running. Work overlaps — a sync
* starts during a crawl and ends after it — so each line is removed by
* itself rather than by putting back whatever was showing before, which
* once brought back a finished crawl's line and spun it forever.
*/ */
private suspend fun busy(what: String, work: suspend () -> Unit) { private suspend fun busy(what: String, work: suspend () -> Unit) {
val outer = mutableState.value.activity val token = nextActivity++
mutableState.update { it.copy(activity = what, refreshing = true) } activities += token to what
showActivity()
try { try {
work() work()
} finally { } finally {
mutableState.update { it.copy(activity = outer, refreshing = outer != null) } activities.removeAll { it.first == token }
showActivity()
} }
} }
private fun showActivity() {
val newest = activities.lastOrNull()?.second
mutableState.update { it.copy(activity = newest, refreshing = newest != null) }
}
/** /**
* Recrawl one share rather than the whole library. * Recrawl one share rather than the whole library.
* *
@@ -197,7 +226,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } } runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
} }
reload() reloadNow()
matchAfterCrawl()
} }
} }
@@ -213,15 +243,25 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
viewModelScope.launch { match(force) } viewModelScope.launch { match(force) }
} }
private suspend fun match(force: Boolean) { /**
* With [quiet], a run nobody asked for says nothing when it found nothing
* to do.
*/
private suspend fun match(force: Boolean, quiet: Boolean = false) {
busy("Looking up titles…") { busy("Looking up titles…") {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } } runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
.onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } } .onSuccess { summary ->
if (!quiet || !summary.isEmpty()) {
mutableState.update { it.copy(message = describe(summary)) }
}
}
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
} }
reloadNow() reloadNow()
} }
private fun MatchSummary.isEmpty() = matched == 0u && unmatched == 0u && failed == 0u && episodes == 0u
/** /**
* Add a link, and return why it was refused, if it was. * Add a link, and return why it was refused, if it was.
* *
@@ -261,7 +301,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
mutableState.update { it.copy(message = failed.joinToString("\n")) } mutableState.update { it.copy(message = failed.joinToString("\n")) }
return return
} }
if (mutableState.value.metadataSettings.enabled) match(force = false) matchAfterCrawl()
if (announce) { if (announce) {
val added = (mutableState.value.titles.map { it.key }.toSet() - before).size val added = (mutableState.value.titles.map { it.key }.toSet() - before).size
mutableState.update { it.copy(message = if (added == 1) "1 title added" else "$added titles added") } mutableState.update { it.copy(message = if (added == 1) "1 title added" else "$added titles added") }
@@ -305,7 +345,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
engine.crawl(id) engine.crawl(id)
} }
}.onFailure(::reportError) }.onFailure(::reportError)
reload() reloadNow()
matchAfterCrawl()
} }
} }
+2
View File
@@ -625,6 +625,7 @@ impl App {
// that was crawled yesterday is on screen before the shares open. // that was crawled yesterday is on screen before the shares open.
engine.load_shares(); engine.load_shares();
engine.load_library(); engine.load_library();
engine.forget_orphaned_shares();
engine.load_metadata(); engine.load_metadata();
engine.connect(); engine.connect();
let settings = engine.metadata_config(); let settings = engine.metadata_config();
@@ -819,6 +820,7 @@ impl App {
} }
self.note(ctx, text, failed > 0); self.note(ctx, text, failed > 0);
} }
Event::MatchStarted => self.matching = true,
Event::MatchFinished => self.matching = false, Event::MatchFinished => self.matching = false,
// Both of these are addressed to a dialog, and the viewer may // Both of these are addressed to a dialog, and the viewer may
// have closed it or opened another one while the request was // have closed it or opened another one while the request was
+68 -2
View File
@@ -224,6 +224,8 @@ pub enum Event {
unmatched: usize, unmatched: usize,
failed: usize, failed: usize,
}, },
/// A matching run has work to do — one asked for, or one after a crawl.
MatchStarted,
/// Every requested match is done. /// Every requested match is done.
MatchFinished, MatchFinished,
/// What the provider answered a hand search with, unscored and in its own /// What the provider answered a hand search with, unscored and in its own
@@ -998,6 +1000,35 @@ impl Engine {
}); });
} }
/// Forget every share the catalog holds files for that the share list no
/// longer names — what an older `pstr remove` left behind. Its files would
/// otherwise sit in the library beside whatever replaced it, every episode
/// twice.
pub fn forget_orphaned_shares(&self) {
let engine = self.clone();
self.runtime.spawn(async move {
let configured = match engine.store.list() {
Ok(shares) => shares,
Err(error) => return engine.fail("read the share list", error),
};
let orphans: Vec<String> = match engine.catalog.lock().share_ids() {
Ok(ids) => ids
.into_iter()
.filter(|id| !configured.iter().any(|share| &share.id == id))
.collect(),
Err(error) => return engine.fail("read the catalog", error),
};
if orphans.is_empty() {
return;
}
for id in orphans {
tracing::info!(share = %id, "forgetting the catalog rows of a removed share");
engine.forget_share(id, false).await;
}
engine.load_library();
});
}
/// Stop a share's downloads and delete its files and catalog rows, and, /// Stop a share's downloads and delete its files and catalog rows, and,
/// when it is still `configured` — not already removed by sync — its /// when it is still `configured` — not already removed by sync — its
/// entry and secrets. /// entry and secrets.
@@ -1271,6 +1302,32 @@ impl Engine {
) )
.await; .await;
self.emit(Event::CrawlFinished); self.emit(Event::CrawlFinished);
self.match_after_crawl().await;
}
/// Look up whatever the crawl brought that has no answer yet — a new show,
/// or a new episode the stored guide cannot name. Silent when there is
/// nothing to ask, which is almost every time: `plan` makes no requests for
/// a library that is already matched.
async fn match_after_crawl(&self) {
if !self.metadata_config().enabled {
return;
}
let titles = {
let catalog = self.catalog.lock();
match catalog
.all_files()
.and_then(|files| Ok(Library::build(files, &catalog.all_watch_states()?)))
{
Ok(library) => library.titles,
Err(error) => return self.fail("read the catalog", error),
}
};
if titles.is_empty() {
return;
}
self.run_match(titles, false, true).await;
self.emit(Event::MatchFinished);
} }
/// Stop every crawl still listing. Shares already past their listing /// Stop every crawl still listing. Shares already past their listing
@@ -2306,13 +2363,18 @@ impl Engine {
pub fn match_titles(&self, titles: Vec<Title>, force: bool) { pub fn match_titles(&self, titles: Vec<Title>, force: bool) {
let engine = self.clone(); let engine = self.clone();
self.runtime.spawn(async move { self.runtime.spawn(async move {
engine.run_match(titles, force).await; engine.run_match(titles, force, false).await;
engine.emit(Event::MatchFinished); engine.emit(Event::MatchFinished);
}); });
} }
async fn run_match(&self, titles: Vec<Title>, force: bool) { /// `automatic` is a run nobody asked for, after a crawl: it says nothing
/// when there is nothing to do, or no provider to do it with.
async fn run_match(&self, titles: Vec<Title>, force: bool, automatic: bool) {
let Some(service) = self.metadata_service().await else { let Some(service) = self.metadata_service().await else {
if automatic {
return;
}
return self.emit(Event::Error(self.no_service_reason())); return self.emit(Event::Error(self.no_service_reason()));
}; };
let provider = service.provider(); let provider = service.provider();
@@ -2337,8 +2399,12 @@ impl Engine {
force, force,
); );
if pending.is_empty() { if pending.is_empty() {
if automatic {
return;
}
return self.emit(Event::Status("everything is already matched".into())); return self.emit(Event::Status("everything is already matched".into()));
} }
self.emit(Event::MatchStarted);
self.emit(Event::Status(format!( self.emit(Event::Status(format!(
"matching {} titles against {}…", "matching {} titles against {}…",
pending.len(), pending.len(),
+18
View File
@@ -377,6 +377,24 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
Command::Remove { id } => { Command::Remove { id } => {
store.remove(&id).context("remove share")?; store.remove(&id).context("remove share")?;
// Its catalog rows go too, or the library keeps listing its files —
// beside the new share's, when the same folder was added back
// another way.
let mut catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let offline = catalog
.offline_files_for_share(&id)
.context("read the share's downloads")?;
for (link_id, file) in &offline {
match std::fs::remove_file(dirs.offline_file(&id, link_id, &file.revision_id)) {
Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
return Err(error).context("delete a downloaded file");
}
_ => {}
}
}
catalog
.remove_share(&id)
.context("drop the share's catalog rows")?;
println!("removed {id}"); println!("removed {id}");
} }
+278 -5
View File
@@ -254,6 +254,16 @@ fn title_track_prefs_row(
/// What `season = -1` means in `episode_metadata`: no season at all. /// What `season = -1` means in `episode_metadata`: no season at all.
const ABSOLUTE_SEASON: i64 = -1; const ABSOLUTE_SEASON: i64 = -1;
/// How long after an episode airs its guide keeps being asked about daily
/// while the provider has no name for it.
///
/// A guide is otherwise trusted as long as a match, a month, and one fetched
/// the week before an episode aired lists it unnamed — "Episode 2" with a
/// date — until the month is up. A name usually follows the air date by a day
/// or so; one that has not come in two weeks is not coming, and asking every
/// day for the rest of time would not change that.
const GUIDE_WAIT_DAYS: i64 = 14;
/// One catalog row: a file or folder inside a share. /// One catalog row: a file or folder inside a share.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogNode { pub struct CatalogNode {
@@ -513,6 +523,17 @@ impl Catalog {
Ok(rows.collect::<std::result::Result<_, _>>()?) Ok(rows.collect::<std::result::Result<_, _>>()?)
} }
/// Every share the catalog holds files for. One no longer configured was
/// forgotten without [`Catalog::remove_share`] — by an older `pstr remove`
/// — and its rows still show up in the library beside whatever replaced it.
pub fn share_ids(&self) -> Result<Vec<String>> {
let mut statement = self
.conn
.prepare("SELECT DISTINCT share_id FROM nodes ORDER BY share_id")?;
let rows = statement.query_map([], |row| row.get(0))?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
/// Drop everything a share contributed except where it was watched. /// Drop everything a share contributed except where it was watched.
/// ///
/// Watch state stays, as through a recrawl. It is history, and not only /// Watch state stays, as through a recrawl. It is history, and not only
@@ -521,8 +542,34 @@ impl Catalog {
/// only make it flicker. Nothing shows it — the library pairs positions /// only make it flicker. Nothing shows it — the library pairs positions
/// with files, and the files are gone — until the share is added again, /// with files, and the files are gone — until the share is added again,
/// when it picks up where it left off. /// when it picks up where it left off.
///
/// It is also copied, newer wins as in a merge, onto every other share that
/// holds the same file. A public link swapped for the account's own folder
/// reaches the very same links under a new share id, and without the copy
/// the swap reads as a library nobody has watched.
pub fn remove_share(&mut self, share_id: &str) -> Result<()> { pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
let tx = self.conn.transaction()?; let tx = self.conn.transaction()?;
tx.execute(
"INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
SELECT other.share_id, w.link_id, w.position_secs, w.duration_secs, w.watched, w.updated_at
FROM watch_state w
JOIN nodes own ON own.share_id = w.share_id AND own.link_id = w.link_id
JOIN nodes other ON other.volume_id = own.volume_id
AND other.link_id = own.link_id
AND other.share_id <> own.share_id
WHERE w.share_id = ?1
ON CONFLICT (share_id, link_id) DO UPDATE SET
position_secs = excluded.position_secs,
duration_secs = excluded.duration_secs,
watched = excluded.watched,
updated_at = excluded.updated_at
WHERE excluded.updated_at > watch_state.updated_at
OR (excluded.updated_at = watch_state.updated_at
AND (excluded.watched > watch_state.watched
OR (excluded.watched = watch_state.watched
AND excluded.position_secs > watch_state.position_secs)))",
params![share_id],
)?;
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?; tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
tx.execute( tx.execute(
"DELETE FROM offline_files WHERE share_id = ?1", "DELETE FROM offline_files WHERE share_id = ?1",
@@ -1030,14 +1077,27 @@ impl Catalog {
} }
/// When each matched title was last enriched, for the provider it is /// When each matched title was last enriched, for the provider it is
/// matched against. A title missing here has never been, or its match has /// matched against. A title missing here has never been, its match has
/// changed since. /// changed since, or its guide is waiting on the name of an episode that
/// has aired since — see `GUIDE_WAIT_DAYS`.
pub fn enrichment_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> { pub fn enrichment_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> {
self.enrichment_ages_at(unix_now())
}
fn enrichment_ages_at(&self, now: i64) -> Result<HashMap<String, (ProviderId, i64)>> {
let mut statement = self.conn.prepare( let mut statement = self.conn.prepare(
"SELECT title_key, provider, enriched_at FROM title_metadata "SELECT title_key, provider, enriched_at FROM title_metadata t
WHERE enriched_at IS NOT NULL", WHERE enriched_at IS NOT NULL
AND NOT (enriched_at < ?1 - 86400
AND EXISTS (
SELECT 1 FROM episode_metadata e
WHERE e.title_key = t.title_key
AND e.name IS NULL
AND e.air_date <= date(?1, 'unixepoch')
AND e.air_date >= date(t.enriched_at, 'unixepoch',
'-' || ?2 || ' days')))",
)?; )?;
let rows = statement.query_map([], |row| { let rows = statement.query_map(params![now, GUIDE_WAIT_DAYS], |row| {
Ok(( Ok((
row.get::<_, String>(0)?, row.get::<_, String>(0)?,
row.get::<_, String>(1)?, row.get::<_, String>(1)?,
@@ -1052,6 +1112,36 @@ impl Catalog {
ages.insert(title_key, (provider, enriched_at)); ages.insert(title_key, (provider, enriched_at));
} }
} }
drop(statement);
// A file that arrived after the guide was fetched, and that the guide
// has no name for, is an episode the guide predates: the next one of a
// running show. Asked again once — the refetch is newer than the file,
// so a name the provider never gives does not make it ask forever.
if !ages.is_empty() {
let first_seen = self.first_seen()?;
let guides = self.all_episode_metadata()?;
for node in self.all_files()? {
let Some(number) = node.parsed.episode else {
continue;
};
let key = crate::library::title_key(&node.parsed.title);
let Some((_, enriched_at)) = ages.get(&key) else {
continue;
};
let seen = first_seen
.get(&(node.share_id, node.link_id))
.copied()
.unwrap_or(0);
let named = guides
.get(&key)
.and_then(|guide| guide.get(node.parsed.season, number))
.is_some_and(|episode| episode.name.is_some());
if seen > *enriched_at && !named {
ages.remove(&key);
}
}
}
Ok(ages) Ok(ages)
} }
} }
@@ -1844,6 +1934,129 @@ mod tests {
assert!(catalog.enrichment_ages().expect("ages").is_empty()); assert!(catalog.enrichment_ages().expect("ages").is_empty());
} }
#[test]
fn a_file_newer_than_its_guide_that_the_guide_cannot_name_asks_for_the_guide_again() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.set_metadata(&MetadataRecord {
title_key: "kusuriya".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1_000,
manual: false,
})
.expect("store");
let enrichment = Enrichment {
episodes: vec![EpisodeMetadata {
season: Some(3),
number: 1,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("Locusts".into()),
overview: None,
still_url: None,
air_date: None,
}],
backdrop_url: None,
chain: Vec::new(),
};
catalog
.set_enrichment("kusuriya", ProviderId::AniList, 1_000, &enrichment)
.expect("enrich");
let trusted = |catalog: &Catalog| {
catalog
.enrichment_ages()
.expect("ages")
.contains_key("kusuriya")
};
// Newer than the guide, but the guide names it.
let first = file("s", "e1", "Kusuriya S03E01.mkv");
catalog
.replace_share("s", std::slice::from_ref(&first))
.expect("crawl");
assert!(trusted(&catalog));
// The next episode, which the guide does not know yet.
catalog
.replace_share("s", &[first, file("s", "e2", "Kusuriya S03E02.mkv")])
.expect("crawl again");
assert!(!trusted(&catalog));
// Fetched again since, still without it: not asked about again.
catalog
.set_enrichment("kusuriya", ProviderId::AniList, unix_now() + 1, &enrichment)
.expect("enrich again");
assert!(trusted(&catalog));
}
#[test]
fn a_guide_fetched_before_an_episode_had_a_name_is_asked_again_once_it_airs() {
const DAY: i64 = 86_400;
// 2026-10-04, when the guide was fetched; episode 2 airs on the 9th.
let fetched = 1_791_072_000;
let mut catalog = Catalog::in_memory().expect("open");
catalog
.set_metadata(&MetadataRecord {
title_key: "kusuriya".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: fetched,
manual: false,
})
.expect("store");
let episode = |number: u32, name: Option<&str>, air_date: &str| EpisodeMetadata {
season: Some(3),
number,
absolute: None,
entry_season: Some(1),
entry_number: Some(number),
name: name.map(str::to_owned),
overview: None,
still_url: None,
air_date: Some(air_date.into()),
};
let enrichment = Enrichment {
episodes: vec![
episode(1, Some("Locusts"), "2026-10-02"),
episode(2, None, "2026-10-09"),
],
backdrop_url: None,
chain: Vec::new(),
};
catalog
.set_enrichment("kusuriya", ProviderId::AniList, fetched, &enrichment)
.expect("enrich");
let trusted = |catalog: &Catalog, now: i64| {
catalog
.enrichment_ages_at(now)
.expect("ages")
.contains_key("kusuriya")
};
// Before episode 2 airs, a nameless future episode is no reason.
assert!(trusted(&catalog, fetched + 4 * DAY));
// Once it has aired, the guide is asked again.
assert!(!trusted(&catalog, fetched + 8 * DAY));
// Asked on the 12th and still nameless: not again the same day…
let refetched = fetched + 8 * DAY;
catalog
.set_enrichment("kusuriya", ProviderId::AniList, refetched, &enrichment)
.expect("enrich again");
assert!(trusted(&catalog, refetched + DAY / 2));
// …but the next.
assert!(!trusted(&catalog, refetched + DAY + 1));
// Two weeks past the air date it is given up on.
let given_up = fetched + 21 * DAY;
catalog
.set_enrichment("kusuriya", ProviderId::AniList, given_up, &enrichment)
.expect("enrich late");
assert!(trusted(&catalog, given_up + 2 * DAY));
}
/// Details round-trip, and refreshing a match's details — the same entry /// Details round-trip, and refreshing a match's details — the same entry
/// stored again — keeps what enrichment found for it. /// stored again — keeps what enrichment found for it.
#[test] #[test]
@@ -2460,6 +2673,66 @@ mod tests {
assert_eq!(library.titles[0].seasons[0].episodes[0].watch, Some(state)); assert_eq!(library.titles[0].seasons[0].episodes[0].watch, Some(state));
} }
#[test]
fn removing_a_share_hands_its_watch_state_to_another_share_holding_the_same_file() {
let mut catalog = Catalog::in_memory().expect("open");
let at = |position_secs: f64, updated_at: i64| WatchState {
position_secs,
duration_secs: Some(1440.0),
watched: false,
updated_at,
};
catalog
.replace_share(
"link",
&[
file("link", "a", "Show S01E01.mkv"),
file("link", "b", "Show S01E02.mkv"),
file("link", "gone", "Show S01E03.mkv"),
],
)
.expect("crawl the link");
catalog
.replace_share(
"folder",
&[
file("folder", "a", "Show S01E01.mkv"),
file("folder", "b", "Show S01E02.mkv"),
],
)
.expect("crawl the folder");
catalog
.set_watch_state("link", "a", &at(100.0, 10))
.unwrap();
catalog
.set_watch_state("link", "b", &at(100.0, 10))
.unwrap();
catalog
.set_watch_state("link", "gone", &at(100.0, 10))
.unwrap();
// Watched through the folder since: the link's older position must
// not overwrite it.
catalog
.set_watch_state("folder", "b", &at(900.0, 20))
.unwrap();
catalog.remove_share("link").expect("remove");
assert_eq!(
catalog.watch_state("folder", "a").unwrap(),
Some(at(100.0, 10))
);
assert_eq!(
catalog.watch_state("folder", "b").unwrap(),
Some(at(900.0, 20))
);
assert_eq!(catalog.watch_state("folder", "gone").unwrap(), None);
assert_eq!(
catalog.watch_state("link", "a").unwrap(),
Some(at(100.0, 10))
);
}
#[test] #[test]
fn merging_takes_only_states_newer_than_the_recorded_ones() { fn merging_takes_only_states_newer_than_the_recorded_ones() {
let mut catalog = Catalog::in_memory().expect("open"); let mut catalog = Catalog::in_memory().expect("open");
+5
View File
@@ -79,6 +79,11 @@ Each of these is the platform's doing, not a choice to diverge.
place of Android's `WatchSyncWorker`, and every five minutes while it is open. place of Android's `WatchSyncWorker`, and every five minutes while it is open.
- **Background audio off pauses** the episode when the app leaves the screen, where - **Background audio off pauses** the episode when the app leaves the screen, where
Android stops it. Android stops it.
- **Presses are drawn by hand.** Compose's ripple shows every press; SwiftUI's
`.plain` button style and `onTapGesture` show none, which on a slow iPad reads as
a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
stand in for the ripple, and a settings page goes back by an edge strip of its
own, as a title page does, where Android has the system back.
- **Nothing is backed up**, as on Android (`allowBackup="false"`). The share - **Nothing is backed up**, as on Android (`allowBackup="false"`). The share
secrets are this-device-only Keychain items, so a restored share list would secrets are this-device-only Keychain items, so a restored share list would
arrive without them, and a restored `sync.json` would give two devices one sync arrive without them, and a restored `sync.json` would give two devices one sync
+122 -51
View File
@@ -64,6 +64,9 @@ final class AppModel {
@ObservationIgnored private var started = false @ObservationIgnored private var started = false
/// Launch and the first `.active` both ask; one crawl answers. /// Launch and the first `.active` both ask; one crawl answers.
@ObservationIgnored private var catchingUp = false @ObservationIgnored private var catchingUp = false
/// Every banner line whose work is still running, oldest first.
@ObservationIgnored private var activities: [(token: Int, line: String)] = []
@ObservationIgnored private var nextActivity = 0
/// Five minutes, as on Android: cheap when nothing changed — one folder /// Five minutes, as on Android: cheap when nothing changed — one folder
/// listing, and no upload. /// listing, and no upload.
@@ -142,7 +145,19 @@ final class AppModel {
message = errorMessage(error) message = errorMessage(error)
} }
} }
reload() await reloadNow()
// Not awaited: pull to refresh lets go once the crawl is in, and the
// banner carries the lookup.
Task { await matchAfterCrawl() }
}
/// Look up whatever a crawl brought that has no answer yet: a new show, or
/// a new episode its stored guide cannot name. When everything already
/// has one the bridge asks nothing, so this is cheap after every crawl.
private func matchAfterCrawl() async {
if metadataSettings.enabled, metadataSettings.ready {
await match(force: false, quiet: true)
}
} }
/// Run enrichment again. With `force` every title is looked up afresh, /// Run enrichment again. With `force` every title is looked up afresh,
@@ -151,11 +166,16 @@ final class AppModel {
Task { await match(force: force) } Task { await match(force: force) }
} }
private func match(force: Bool) async { /// With `quiet`, a run nobody asked for says nothing when it found
/// nothing to do.
private func match(force: Bool, quiet: Bool = false) async {
await busy("Looking up titles…") { await busy("Looking up titles…") {
do { do {
let summary = try await NativeRuntime.engine().matchTitles(force: force) let summary = try await NativeRuntime.engine().matchTitles(force: force)
message = Self.describe(summary) let found = summary.matched + summary.unmatched + summary.failed + summary.episodes > 0
if !quiet || found {
message = Self.describe(summary)
}
} catch { } catch {
message = errorMessage(error) message = errorMessage(error)
} }
@@ -163,15 +183,25 @@ final class AppModel {
reload() reload()
} }
/// Say `what` in the activity banner while `work` runs. Nested work keeps /// Say `what` in the activity banner while `work` runs.
/// the outer line once the inner one is done. ///
/// The banner shows the newest line still running. Work overlaps — a sync
/// starts during a crawl and ends after it — so each line is removed by
/// itself rather than by putting back whatever was showing before, which
/// once brought back a finished crawl's line and spun it forever.
private func busy(_ what: String, _ work: () async -> Void) async { private func busy(_ what: String, _ work: () async -> Void) async {
let outer = activity let token = nextActivity
activity = what nextActivity += 1
refreshing = true activities.append((token, what))
showActivity()
await work() await work()
activity = outer activities.removeAll { $0.token == token }
refreshing = outer != nil showActivity()
}
private func showActivity() {
activity = activities.last?.line
refreshing = activity != nil
} }
// MARK: - Shares // MARK: - Shares
@@ -216,9 +246,7 @@ final class AppModel {
message = failed.joined(separator: "\n") message = failed.joined(separator: "\n")
return return
} }
if metadataSettings.enabled { await matchAfterCrawl()
await match(force: false)
}
if announce { if announce {
let added = Set(titles.map(\.key)).subtracting(before).count let added = Set(titles.map(\.key)).subtracting(before).count
message = added == 1 ? "1 title added" : "\(added) titles added" message = added == 1 ? "1 title added" : "\(added) titles added"
@@ -230,34 +258,41 @@ final class AppModel {
/// offline files alone. /// offline files alone.
func repairShare(id: String, url: String, password: String?) { func repairShare(id: String, url: String, password: String?) {
Task { Task {
do { let name = shares.first { $0.id == id }?.name ?? "the share"
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 } await busy("Reading \(name) again…") {
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) } do {
try await NativeRuntime.engine().crawl(shareId: id) let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
} catch { _ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
reportError(error) try await NativeRuntime.engine().crawl(shareId: id)
} catch {
reportError(error)
}
await reloadNow()
} }
reload() await matchAfterCrawl()
} }
} }
func removeShare(_ id: String) { func removeShare(_ id: String) {
Task { Task {
do { let name = shares.first { $0.id == id }?.name ?? "the share"
// Wait for the transfers to stop before Rust removes catalog await busy("Removing \(name)…") {
// and files. Rust also rejects any late publication. do {
await DownloadCoordinator.shared.cancelShare(id) // Wait for the transfers to stop before Rust removes catalog
let store = DownloadStateStore.shared // and files. Rust also rejects any late publication.
let engine = try await NativeRuntime.engine() await DownloadCoordinator.shared.cancelShare(id)
for download in store.records() where download.shareId == id { let store = DownloadStateStore.shared
try await engine.removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId) let engine = try await NativeRuntime.engine()
for download in store.records() where download.shareId == id {
try await engine.removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId)
}
store.removeShare(id)
try await run { try $0.removeShare(shareId: id) }
} catch {
message = errorMessage(error)
} }
store.removeShare(id) await reloadNow()
try await run { try $0.removeShare(shareId: id) }
} catch {
message = errorMessage(error)
} }
reload()
} }
} }
@@ -326,13 +361,15 @@ final class AppModel {
func signOut() { func signOut() {
Task { Task {
do { await busy("Signing out…") {
try await NativeRuntime.engine().signOut() do {
} catch { try await NativeRuntime.engine().signOut()
reportError(error) } catch {
reportError(error)
}
account = AccountUiState(state: .signedOut)
await reloadNow()
} }
account = AccountUiState(state: .signedOut)
reload()
} }
} }
@@ -441,6 +478,7 @@ final class AppModel {
/// title's positions are what this throws away, and a mis-tap on a tile's /// title's positions are what this throws away, and a mis-tap on a tile's
/// menu should not cost them. /// menu should not cost them.
func setTitleWatched(_ title: TitleRecord, _ watched: Bool) { func setTitleWatched(_ title: TitleRecord, _ watched: Bool) {
showWatched(title.playlist, watched)
changeWatch(title.playlist, "\(title.displayName) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in changeWatch(title.playlist, "\(title.displayName) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in
let duration = before?.durationSecs let duration = before?.durationSecs
try engine.saveWatchState( try engine.saveWatchState(
@@ -571,12 +609,14 @@ final class AppModel {
func removeOffline(_ file: OfflineRecord) { func removeOffline(_ file: OfflineRecord) {
Task { Task {
do { await busy("Deleting the download…") {
try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId) do {
} catch { try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId)
message = errorMessage(error) } catch {
message = errorMessage(error)
}
await reloadNow()
} }
reload()
} }
} }
@@ -599,14 +639,16 @@ final class AppModel {
func removeAllOffline() { func removeAllOffline() {
Task { Task {
await DownloadCoordinator.shared.cancelAll() await busy("Deleting every download…") {
do { await DownloadCoordinator.shared.cancelAll()
try await NativeRuntime.engine().removeAllOffline() do {
DownloadStateStore.shared.clear() try await NativeRuntime.engine().removeAllOffline()
} catch { DownloadStateStore.shared.clear()
reportError(error) } catch {
reportError(error)
}
await reloadNow()
} }
reload()
} }
} }
@@ -651,6 +693,7 @@ final class AppModel {
/// the position at its duration where one is known, so the progress bar /// the position at its duration where one is known, so the progress bar
/// agrees with the tick. /// agrees with the tick.
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) { func setWatched(_ episode: EpisodeRecord, _ watched: Bool) {
showWatched([episode], watched)
Task { Task {
do { do {
try await run { engine in try await run { engine in
@@ -670,6 +713,34 @@ final class AppModel {
} }
} }
/// Draw a watched mark the moment it is tapped, before the bridge has
/// stored it. The reload that shows the stored state is a second away on
/// an old iPad, and a tick that does not appear gets tapped again. That
/// reload still follows, and puts back whatever was really stored.
private func showWatched(_ targets: [EpisodeRecord], _ watched: Bool) {
let keys = Set(targets.map { "\($0.shareId)/\($0.linkId)" })
titles = titles.map { title in
guard title.playlist.contains(where: { keys.contains("\($0.shareId)/\($0.linkId)") }) else { return title }
var title = title
title.seasons = title.seasons.map { season in
var season = season
season.episodes = season.episodes.map { episode in
guard keys.contains("\(episode.shareId)/\(episode.linkId)") else { return episode }
var episode = episode
episode.watched = watched
if !watched {
episode.progress = nil
episode.resumeAt = nil
}
return episode
}
return season
}
title.watchedCount = UInt64(title.playlist.filter(\.watched).count)
return title
}
}
// MARK: - Reload // MARK: - Reload
func reload() { func reload() {
+3 -3
View File
@@ -262,7 +262,7 @@ private struct NavigationBar: View {
.padding(.bottom, 6) .padding(.bottom, 6)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.accessibilityLabel(item.label) .accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : []) .accessibilityAddTraits(selected ? .isSelected : [])
} }
@@ -295,7 +295,7 @@ private struct NavigationRail: View {
.frame(width: 80) .frame(width: 80)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.accessibilityLabel(item.label) .accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : []) .accessibilityAddTraits(selected ? .isSelected : [])
} }
@@ -350,7 +350,7 @@ private struct Snackbar: View {
Button("Undo", action: model.performUndo) Button("Undo", action: model.performUndo)
.textStyle(.labelLarge) .textStyle(.labelLarge)
.foregroundStyle(scheme.inversePrimary) .foregroundStyle(scheme.inversePrimary)
.buttonStyle(.plain) .buttonStyle(.pressable)
.padding(.horizontal, 8) .padding(.horizontal, 8)
} }
} }
+16 -2
View File
@@ -77,6 +77,10 @@ final class PlayerHost {
private(set) var chapters = ChapterPlan(entries: [], creditsStart: nil) private(set) var chapters = ChapterPlan(entries: [], creditsStart: nil)
/// What stopped this episode, until the viewer dismisses it. /// What stopped this episode, until the viewer dismisses it.
var playbackError: String? var playbackError: String?
/// The stream is being opened: the share session, the revision and its
/// block list, all before mpv has a byte. On an old iPad that is seconds
/// of a black screen unless something says so.
private(set) var opening = false
/// Read once per episode: a preference the viewer changes mid-episode /// Read once per episode: a preference the viewer changes mid-episode
/// applies to the next one. /// applies to the next one.
private(set) var autoSkip = false private(set) var autoSkip = false
@@ -107,6 +111,11 @@ final class PlayerHost {
var episode: EpisodeRecord? { episodes.indices.contains(index) ? episodes[index] : nil } var episode: EpisodeRecord? { episodes.indices.contains(index) ? episodes[index] : nil }
var key: String? { episode.map(mediaKey) } var key: String? { episode.map(mediaKey) }
var next: EpisodeRecord? { episodes.indices.contains(index + 1) ? episodes[index + 1] : nil } var next: EpisodeRecord? { episodes.indices.contains(index + 1) ? episodes[index + 1] : nil }
/// Whether mpv's readings — position, chapters — are the open episode's.
/// Not while the next one opens: the last one's credits go on playing
/// until it is loaded, and read against the new index they offered the
/// episode *after* the next as up next.
var playingOpenEpisode: Bool { player?.media != nil && player?.media == key }
var isOpen: Bool { title != nil } var isOpen: Bool { title != nil }
// MARK: - Opening // MARK: - Opening
@@ -177,6 +186,7 @@ final class PlayerHost {
index = 0 index = 0
minimized = false minimized = false
playbackError = nil playbackError = nil
opening = false
chapters = ChapterPlan(entries: [], creditsStart: nil) chapters = ChapterPlan(entries: [], creditsStart: nil)
OrientationLock.set(false) OrientationLock.set(false)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
@@ -210,6 +220,7 @@ final class PlayerHost {
return return
} }
try? AVAudioSession.sharedInstance().setActive(true) try? AVAudioSession.sharedInstance().setActive(true)
opening = true
loadTask = Task { loadTask = Task {
do { do {
try await session.open() try await session.open()
@@ -244,6 +255,8 @@ final class PlayerHost {
hardwareDecoding: SettingsStore().hardwareDecoding hardwareDecoding: SettingsStore().hardwareDecoding
) )
guard loaded else { throw PlaybackFailure("libmpv rejected the stream") } guard loaded else { throw PlaybackFailure("libmpv rejected the stream") }
// mpv's own buffering takes over the spinner from here.
opening = false
session.transferToPlayer() session.transferToPlayer()
// After the load, because mpv keeps none of the three across one. // After the load, because mpv keeps none of the three across one.
player.setVolume(global.volume) player.setVolume(global.volume)
@@ -256,6 +269,7 @@ final class PlayerHost {
abandon(session) abandon(session)
return return
} }
opening = false
playbackError = errorMessage(error) playbackError = errorMessage(error)
} }
} }
@@ -320,7 +334,7 @@ final class PlayerHost {
} }
private func tick() { private func tick() {
guard let player else { return } guard let player, playingOpenEpisode else { return }
replanChapters(player) replanChapters(player)
// Auto-skip, once per chapter. // Auto-skip, once per chapter.
if autoSkip, !player.paused, let offer = skipOffer(plan: chapters, position: player.position), if autoSkip, !player.paused, let offer = skipOffer(plan: chapters, position: player.position),
@@ -343,7 +357,7 @@ final class PlayerHost {
/// The skip button's offer at the current position, if any. /// The skip button's offer at the current position, if any.
var offer: SkipOffer? { var offer: SkipOffer? {
guard let player else { return nil } guard let player, playingOpenEpisode else { return nil }
return skipOffer(plan: chapters, position: player.position) return skipOffer(plan: chapters, position: player.position)
} }
+9 -6
View File
@@ -63,7 +63,8 @@ struct PlayerView: View {
} }
UpNextCard( UpNextCard(
next: host.next, next: host.next,
visible: host.autoplay && !player.paused && host.chapters.creditsStart.map { player.position >= $0 } == true, visible: host.autoplay && !player.paused && host.playingOpenEpisode
&& host.chapters.creditsStart.map { player.position >= $0 } == true,
onPlayNow: host.advance onPlayNow: host.advance
) )
} }
@@ -73,10 +74,12 @@ struct PlayerView: View {
// Only while nothing else explains the still picture: an error // Only while nothing else explains the still picture: an error
// is the more useful thing to say. // is the more useful thing to say.
if player.stalled && host.playbackError == nil { if (host.opening || player.stalled) && host.playbackError == nil {
VStack(spacing: 12) { VStack(spacing: 12) {
ProgressView().controlSize(.large).tint(scheme.primary) ProgressView().controlSize(.large).tint(scheme.primary)
if player.buffering { if host.opening {
Text("Opening…").textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
} else if player.buffering {
Text(player.cachePercent > 0 ? "Buffering… \(Int(player.cachePercent.rounded()))%" : "Buffering…") Text(player.cachePercent > 0 ? "Buffering… \(Int(player.cachePercent.rounded()))%" : "Buffering…")
.textStyle(.labelMedium).foregroundStyle(scheme.onSurface) .textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
} }
@@ -295,7 +298,7 @@ private struct CompactIcon: View {
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(PressableStyle(circle: true, wash: .white))
.disabled(!enabled) .disabled(!enabled)
.accessibilityLabel(label) .accessibilityLabel(label)
} }
@@ -342,7 +345,7 @@ private struct UpNextCard: View {
} }
.clipShape(RoundedRectangle(cornerRadius: Corner.standard)) .clipShape(RoundedRectangle(cornerRadius: Corner.standard))
} }
.buttonStyle(.plain) .buttonStyle(PressableStyle(wash: .white))
.accessibilityLabel("Play now") .accessibilityLabel("Play now")
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Up next in \(remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant) Text("Up next in \(remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
@@ -515,7 +518,7 @@ private struct EpisodeSheet: View {
.background(current ? scheme.secondaryContainer : .clear) .background(current ? scheme.secondaryContainer : .clear)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
} }
} }
+1 -1
View File
@@ -383,7 +383,7 @@ private struct DriveRow: View {
.padding(.vertical, 12) .padding(.vertical, 12)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.disabled(open == nil) .disabled(open == nil)
} }
} }
+1 -1
View File
@@ -69,7 +69,7 @@ struct IconButton: View {
.frame(width: 48, height: 48) .frame(width: 48, height: 48)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressableIcon)
.foregroundStyle(enabled ? tint ?? scheme.onSurfaceVariant : scheme.onSurface.opacity(0.38)) .foregroundStyle(enabled ? tint ?? scheme.onSurfaceVariant : scheme.onSurface.opacity(0.38))
.disabled(!enabled) .disabled(!enabled)
.accessibilityLabel(label) .accessibilityLabel(label)
+2 -2
View File
@@ -57,7 +57,7 @@ struct LanguageSetting: View {
Button { choosing = true } label: { Button { choosing = true } label: {
ListRow(headline: headline, supporting: languageLabel(value)) ListRow(headline: headline, supporting: languageLabel(value))
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.sheet(isPresented: $choosing) { .sheet(isPresented: $choosing) {
LanguageSheet(headline: headline, value: value) { tag in LanguageSheet(headline: headline, value: value) { tag in
choosing = false choosing = false
@@ -114,7 +114,7 @@ private struct LanguageSheet: View {
.padding(.vertical, 10) .padding(.vertical, 10)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.accessibilityAddTraits(selected ? .isSelected : []) .accessibilityAddTraits(selected ? .isSelected : [])
} }
} }
+19 -14
View File
@@ -55,6 +55,9 @@ struct LibraryView: View {
@State private var menuFor: TitleRecord? @State private var menuFor: TitleRecord?
@State private var menuResume: Resumable? @State private var menuResume: Resumable?
@State private var matching: TitleRecord? @State private var matching: TitleRecord?
/// "Change match" picked in the tile menu, waiting for that menu to finish
/// leaving: a sheet presented while another is still dismissing is dropped.
@State private var pendingMatch: TitleRecord?
// Search is an icon until it is wanted: a permanent field above the // Search is an icon until it is wanted: a permanent field above the
// library spent a band of every visit on something used in few of them. // library spent a band of every visit on something used in few of them.
@SceneStorage("library.searching") private var searching = false @SceneStorage("library.searching") private var searching = false
@@ -90,7 +93,15 @@ struct LibraryView: View {
content(resumable) content(resumable)
} }
.background(scheme.background) .background(scheme.background)
.sheet(item: $menuFor) { title in .sheet(item: $menuFor, onDismiss: {
// After the dismissal rather than a guessed delay: on a slow iPad
// the menu was still leaving after 350 ms, the search sheet was
// dropped, and it turned up later on some unrelated redraw.
if let title = pendingMatch {
pendingMatch = nil
matching = title
}
}) { title in
let playlist = title.playlist let playlist = title.playlist
let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount
TileMenu(heading: title.displayName, caption: title.caption) { TileMenu(heading: title.displayName, caption: title.caption) {
@@ -104,12 +115,7 @@ struct LibraryView: View {
} }
// The same search the title page offers, for the tile that is // The same search the title page offers, for the tile that is
// plainly wrong — the wrong poster is what the grid shows. // plainly wrong — the wrong poster is what the grid shows.
MenuRow("pencil", "Change match") { MenuRow("pencil", "Change match") { pendingMatch = title }
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(350))
matching = title
}
}
} }
} }
.sheet(item: $menuResume) { entry in .sheet(item: $menuResume) { entry in
@@ -183,8 +189,9 @@ struct LibraryView: View {
LazyHStack(alignment: .top, spacing: 12) { LazyHStack(alignment: .top, spacing: 12) {
ForEach(resumable) { entry in ForEach(resumable) { entry in
ContinueCard(entry: entry) ContinueCard(entry: entry)
.onTapGesture { onResume(entry.title, entry.index) } .tapAndHold { onResume(entry.title, entry.index) } onHold: {
.onLongPressGesture { longPressed { menuResume = entry } } longPressed { menuResume = entry }
}
} }
} }
} }
@@ -199,8 +206,7 @@ struct LibraryView: View {
ForEach(shelf.titles, id: \.key) { title in ForEach(shelf.titles, id: \.key) { title in
PosterTile(title: title) PosterTile(title: title)
.frame(width: 112) .frame(width: 112)
.onTapGesture { onTitle(title) } .tapAndHold { onTitle(title) } onHold: { longPressed { menuFor = title } }
.onLongPressGesture { longPressed { menuFor = title } }
} }
} }
} }
@@ -214,8 +220,7 @@ struct LibraryView: View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12, alignment: .top)], spacing: 16) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12, alignment: .top)], spacing: 16) {
ForEach(tiles) { tile in ForEach(tiles) { tile in
PosterTile(title: tile.title, folded: tile.folded) PosterTile(title: tile.title, folded: tile.folded)
.onTapGesture { onTitle(tile.title) } .tapAndHold { onTitle(tile.title) } onHold: { longPressed { menuFor = tile.title } }
.onLongPressGesture { longPressed { menuFor = tile.title } }
} }
} }
} }
@@ -357,7 +362,7 @@ struct MenuRow: View {
} label: { } label: {
ListRow(icon: icon, headline: label) ListRow(icon: icon, headline: label)
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.padding(.horizontal, 8) .padding(.horizontal, 8)
} }
} }
+7 -2
View File
@@ -259,7 +259,12 @@ final class ArtworkLoader: @unchecked Sendable {
// A redirect must not leave HTTPS. // A redirect must not leave HTTPS.
http.url?.scheme?.lowercased() == "https" http.url?.scheme?.lowercased() == "https"
else { return nil } else { return nil }
var bytes = Data() // Refused before reading when the server says up front it is too big.
if response.expectedContentLength > Int64(Self.maxBytes) { return nil }
// An array sized once, not a `Data` grown a byte at a time: on an old
// iPad, a screen of posters read that way was seconds of CPU.
var bytes = [UInt8]()
bytes.reserveCapacity(Int(min(max(response.expectedContentLength, 0), Int64(Self.maxBytes))))
do { do {
for try await byte in stream { for try await byte in stream {
bytes.append(byte) bytes.append(byte)
@@ -268,7 +273,7 @@ final class ArtworkLoader: @unchecked Sendable {
} catch { } catch {
return nil return nil
} }
return bytes return Data(bytes)
} }
/// Decode within a bound the display can actually use: the compressed size /// Decode within a bound the display can actually use: the compressed size
+25 -9
View File
@@ -42,6 +42,8 @@ struct SettingsView: View {
@State private var prefs: PlaybackPrefsRecord? @State private var prefs: PlaybackPrefsRecord?
@State private var confirmDelete = false @State private var confirmDelete = false
@State private var showMetadata = false @State private var showMetadata = false
/// How far an edge swipe back has gone, 0 to 1.
@State private var backProgress: CGFloat = 0
@State private var legalDocument: LegalDocument? @State private var legalDocument: LegalDocument?
var body: some View { var body: some View {
@@ -56,7 +58,7 @@ struct SettingsView: View {
Button { page = entry } label: { Button { page = entry } label: {
ListRow(icon: entry.icon, headline: entry.title, supporting: subtitle(entry)) ListRow(icon: entry.icon, headline: entry.title, supporting: subtitle(entry))
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
} }
} }
} }
@@ -115,13 +117,27 @@ struct SettingsView: View {
.padding(.bottom, 24) .padding(.bottom, 24)
} }
} }
// Back by an edge swipe, as from a title page. .scaleEffect(1 - 0.1 * backProgress)
.gesture( .clipShape(RoundedRectangle(cornerRadius: 32 * backProgress))
DragGesture(minimumDistance: 20) // Back by an edge swipe, as from a title page — from a strip of its
.onEnded { drag in // own, as there. On the whole page the swipe began on whichever row
if drag.startLocation.x < 24, drag.translation.width > 80 { self.page = nil } // was under the edge, and lifting the finger pressed it: the way back
} // from Metadata opened the enrichment sheet.
) .overlay(alignment: .leading) {
Color.clear
.frame(width: 20)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 10)
.onChanged { drag in backProgress = min(max(drag.translation.width / 300, 0), 1) }
.onEnded { drag in
if drag.translation.width > 100 || drag.predictedEndTranslation.width > 250 {
self.page = nil
}
withAnimation(.spring) { backProgress = 0 }
}
)
}
} }
@ViewBuilder private var playback: some View { @ViewBuilder private var playback: some View {
@@ -268,7 +284,7 @@ private struct SettingAction: View {
var body: some View { var body: some View {
Button(action: action) { ListRow(headline: headline, supporting: supporting, enabled: enabled) } Button(action: action) { ListRow(headline: headline, supporting: supporting, enabled: enabled) }
.buttonStyle(.plain) .buttonStyle(.pressable)
.disabled(!enabled) .disabled(!enabled)
} }
} }
+1 -1
View File
@@ -51,7 +51,7 @@ struct SharesView: View {
.background(scheme.primary, in: RoundedRectangle(cornerRadius: 16)) .background(scheme.primary, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.3), radius: 6, y: 3) .shadow(color: .black.opacity(0.3), radius: 6, y: 3)
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.padding(16) .padding(16)
} }
} }
+59
View File
@@ -67,6 +67,65 @@ extension ButtonStyle where Self == ProtonButtonStyle {
static var quiet: ProtonButtonStyle { ProtonButtonStyle(kind: .quiet) } static var quiet: ProtonButtonStyle { ProtonButtonStyle(kind: .quiet) }
} }
/// A button with no chrome of its own — an icon, a row, a chip — that still
/// shows it was pressed: a little smaller, with a wash behind it.
///
/// `.plain` shows nothing at all, and on a slow device, where what the press
/// does can take a second to appear, a press that shows nothing reads as one
/// that missed — and gets pressed again.
struct PressableStyle: ButtonStyle {
var circle = false
/// The wash's colour; the scheme's text colour when nil. White over video.
var wash: Color?
@Environment(\.scheme) private var scheme
func makeBody(configuration: Configuration) -> some View {
let pressed = configuration.isPressed
let fill = (wash ?? scheme.onSurface).opacity(pressed ? 0.12 : 0)
configuration.label
.background {
if circle {
Circle().fill(fill)
} else {
RoundedRectangle(cornerRadius: Corner.standard).fill(fill)
}
}
.scaleEffect(pressed ? 0.96 : 1)
.animation(.easeOut(duration: 0.1), value: pressed)
}
}
extension ButtonStyle where Self == PressableStyle {
static var pressable: PressableStyle { PressableStyle() }
static var pressableIcon: PressableStyle { PressableStyle(circle: true) }
}
/// A tap and a long press on something that is not a button — a poster, a
/// card, an episode row — showing the press the moment the finger lands, as
/// `PressableStyle` does for a button.
private struct TapAndHold: ViewModifier {
let onTap: () -> Void
let onHold: (() -> Void)?
@State private var pressed = false
func body(content: Content) -> some View {
content
.scaleEffect(pressed ? 0.96 : 1)
.opacity(pressed ? 0.8 : 1)
.animation(.easeOut(duration: 0.1), value: pressed)
.onTapGesture(perform: onTap)
.onLongPressGesture(perform: { onHold?() }, onPressingChanged: { pressed = $0 })
}
}
extension View {
/// `onTapGesture`, plus `onLongPressGesture` when `onHold` is given, with
/// the press drawn.
func tapAndHold(_ onTap: @escaping () -> Void, onHold: (() -> Void)? = nil) -> some View {
modifier(TapAndHold(onTap: onTap, onHold: onHold))
}
}
/// How far along something is, drawn in the accent. /// How far along something is, drawn in the accent.
/// ///
/// `solid` is for the one bar that is deliberately not the accent: a download in /// `solid` is for the one bar that is deliberately not the accent: a download in
+13 -6
View File
@@ -35,7 +35,9 @@ struct TitleView: View {
self.franchise = franchise self.franchise = franchise
self.onOpenTitle = onOpenTitle self.onOpenTitle = onOpenTitle
// One season on screen at a time, opened on the one Play would land in. // One season on screen at a time, opened on the one Play would land in.
let upNext = title.playlist.indices.contains(nextUpIndex(title.playlist)) ? title.playlist[nextUpIndex(title.playlist)] : nil let playlist = title.playlist
let nextUp = nextUpIndex(playlist)
let upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil
_seasonIndex = State(initialValue: max(title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0, 0)) _seasonIndex = State(initialValue: max(title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0, 0))
} }
@@ -231,7 +233,7 @@ struct TitleView: View {
LazyHStack(alignment: .top, spacing: 12) { LazyHStack(alignment: .top, spacing: 12) {
ForEach(franchise, id: \.key) { member in ForEach(franchise, id: \.key) { member in
FranchiseTile(title: member, here: member.key == title.key) FranchiseTile(title: member, here: member.key == title.key)
.onTapGesture { if member.key != title.key { onOpenTitle(member) } } .tapAndHold { if member.key != title.key { onOpenTitle(member) } }
} }
} }
.padding(.horizontal, side + 16) .padding(.horizontal, side + 16)
@@ -282,7 +284,7 @@ struct FilterChip: View {
if !selected { RoundedRectangle(cornerRadius: Corner.standard).strokeBorder(scheme.outlineVariant) } if !selected { RoundedRectangle(cornerRadius: Corner.standard).strokeBorder(scheme.outlineVariant) }
} }
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
} }
} }
@@ -341,7 +343,7 @@ private struct TitleAction: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
} }
} }
@@ -449,7 +451,7 @@ private struct EpisodeRow: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 6) .padding(.vertical, 6)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture(perform: onPlay) .tapAndHold(onPlay)
} }
} }
@@ -468,6 +470,7 @@ struct ChangeMatchSheet: View {
/// Said here rather than in the snackbar, which this sheet covers. /// Said here rather than in the snackbar, which this sheet covers.
@State private var failure: String? @State private var failure: String?
@State private var searched = false @State private var searched = false
@State private var forgetting = false
init(title: TitleRecord, onDone: @escaping () -> Void) { init(title: TitleRecord, onDone: @escaping () -> Void) {
self.title = title self.title = title
@@ -510,7 +513,9 @@ struct ChangeMatchSheet: View {
.disabled(choosing) .disabled(choosing)
} }
} buttons: { } buttons: {
Button("Forget match") { Button(forgetting ? "Forgetting…" : "Forget match") {
forgetting = true
failure = nil
Task { Task {
do { do {
try await model.run { try $0.forgetMatch(titleKey: title.key) } try await model.run { try $0.forgetMatch(titleKey: title.key) }
@@ -518,9 +523,11 @@ struct ChangeMatchSheet: View {
} catch { } catch {
failure = errorMessage(error) failure = errorMessage(error)
} }
forgetting = false
} }
} }
.buttonStyle(.quiet) .buttonStyle(.quiet)
.disabled(forgetting || choosing)
Button("Close", action: onDone).buttonStyle(.tonal) Button("Close", action: onDone).buttonStyle(.tonal)
} }
} }
+1 -1
View File
@@ -14,7 +14,7 @@
# builds from the registry alone. # builds from the registry alone.
pkgname=proton-stream pkgname=proton-stream
pkgver=1.4.0 pkgver=1.4.1
pkgrel=1 pkgrel=1
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share" pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
arch=('x86_64') arch=('x86_64')