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]]
name = "pstr-android"
version = "1.4.0"
version = "1.4.1"
dependencies = [
"android_logger",
"futures",
@@ -5142,7 +5142,7 @@ dependencies = [
[[package]]
name = "pstr-app"
version = "1.4.0"
version = "1.4.1"
dependencies = [
"anyhow",
"chrono",
@@ -5176,7 +5176,7 @@ dependencies = [
[[package]]
name = "pstr-cli"
version = "1.4.0"
version = "1.4.1"
dependencies = [
"anyhow",
"clap",
@@ -5195,7 +5195,7 @@ dependencies = [
[[package]]
name = "pstr-core"
version = "1.4.0"
version = "1.4.1"
dependencies = [
"directories",
"futures",
@@ -5215,7 +5215,7 @@ dependencies = [
[[package]]
name = "pstr-meta"
version = "1.4.0"
version = "1.4.1"
dependencies = [
"pstr-core",
"reqwest",
@@ -5228,7 +5228,7 @@ dependencies = [
[[package]]
name = "pstr-player"
version = "1.4.0"
version = "1.4.1"
dependencies = [
"async-trait",
"libloading",
@@ -5243,7 +5243,7 @@ dependencies = [
[[package]]
name = "pstr-stream"
version = "1.4.0"
version = "1.4.1"
dependencies = [
"async-trait",
"futures",
+1 -1
View File
@@ -11,7 +11,7 @@ members = [
]
[workspace.package]
version = "1.4.0"
version = "1.4.1"
edition = "2024"
license = "MIT"
repository = "https://github.com/narrrl/proton-stream"
@@ -333,7 +333,12 @@ fun PlayerScreen(
val problem by nativeHost.problem.collectAsState()
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()
}
// Auto-skip is offered once per chapter: seeking back into an opening on
@@ -475,7 +480,7 @@ fun PlayerScreen(
UpNextCard(
next = next,
visible = autoplay && !state.paused &&
visible = autoplay && !state.paused && playingThis &&
chapters.creditsStart?.let { state.position >= it } == true,
onPlayNow = { advance() },
modifier = Modifier
@@ -110,6 +110,13 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
/** Launch and coming back both ask; one crawl answers. */
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 {
if (!NativeRuntime.tlsReady) {
mutableState.update {
@@ -163,24 +170,46 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
}
reload()
reloadNow()
matchAfterCrawl()
}
}
/**
* Say [what] in the activity banner while [work] runs. Nested work keeps
* the outer line once the inner one is done.
* 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 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) {
val outer = mutableState.value.activity
mutableState.update { it.copy(activity = what, refreshing = true) }
val token = nextActivity++
activities += token to what
showActivity()
try {
work()
} 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.
*
@@ -197,7 +226,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
.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) }
}
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…") {
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) } }
}
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.
*
@@ -261,7 +301,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
mutableState.update { it.copy(message = failed.joinToString("\n")) }
return
}
if (mutableState.value.metadataSettings.enabled) match(force = false)
matchAfterCrawl()
if (announce) {
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") }
@@ -305,7 +345,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
engine.crawl(id)
}
}.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.
engine.load_shares();
engine.load_library();
engine.forget_orphaned_shares();
engine.load_metadata();
engine.connect();
let settings = engine.metadata_config();
@@ -819,6 +820,7 @@ impl App {
}
self.note(ctx, text, failed > 0);
}
Event::MatchStarted => self.matching = true,
Event::MatchFinished => self.matching = false,
// Both of these are addressed to a dialog, and the viewer may
// have closed it or opened another one while the request was
+68 -2
View File
@@ -224,6 +224,8 @@ pub enum Event {
unmatched: usize,
failed: usize,
},
/// A matching run has work to do — one asked for, or one after a crawl.
MatchStarted,
/// Every requested match is done.
MatchFinished,
/// 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,
/// when it is still `configured` — not already removed by sync — its
/// entry and secrets.
@@ -1271,6 +1302,32 @@ impl Engine {
)
.await;
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
@@ -2306,13 +2363,18 @@ impl Engine {
pub fn match_titles(&self, titles: Vec<Title>, force: bool) {
let engine = self.clone();
self.runtime.spawn(async move {
engine.run_match(titles, force).await;
engine.run_match(titles, force, false).await;
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 {
if automatic {
return;
}
return self.emit(Event::Error(self.no_service_reason()));
};
let provider = service.provider();
@@ -2337,8 +2399,12 @@ impl Engine {
force,
);
if pending.is_empty() {
if automatic {
return;
}
return self.emit(Event::Status("everything is already matched".into()));
}
self.emit(Event::MatchStarted);
self.emit(Event::Status(format!(
"matching {} titles against {}…",
pending.len(),
+18
View File
@@ -377,6 +377,24 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
Command::Remove { id } => {
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}");
}
+278 -5
View File
@@ -254,6 +254,16 @@ fn title_track_prefs_row(
/// What `season = -1` means in `episode_metadata`: no season at all.
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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogNode {
@@ -513,6 +523,17 @@ impl Catalog {
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.
///
/// 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
/// with files, and the files are gone — until the share is added again,
/// 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<()> {
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 offline_files WHERE share_id = ?1",
@@ -1030,14 +1077,27 @@ impl Catalog {
}
/// 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
/// changed since.
/// matched against. A title missing here has never been, its match has
/// 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)>> {
self.enrichment_ages_at(unix_now())
}
fn enrichment_ages_at(&self, now: i64) -> Result<HashMap<String, (ProviderId, i64)>> {
let mut statement = self.conn.prepare(
"SELECT title_key, provider, enriched_at FROM title_metadata
WHERE enriched_at IS NOT NULL",
"SELECT title_key, provider, enriched_at FROM title_metadata t
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((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
@@ -1052,6 +1112,36 @@ impl Catalog {
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)
}
}
@@ -1844,6 +1934,129 @@ mod tests {
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
/// stored again — keeps what enrichment found for it.
#[test]
@@ -2460,6 +2673,66 @@ mod tests {
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]
fn merging_takes_only_states_newer_than_the_recorded_ones() {
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.
- **Background audio off pauses** the episode when the app leaves the screen, where
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
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
+122 -51
View File
@@ -64,6 +64,9 @@ final class AppModel {
@ObservationIgnored private var started = false
/// Launch and the first `.active` both ask; one crawl answers.
@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
/// listing, and no upload.
@@ -142,7 +145,19 @@ final class AppModel {
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,
@@ -151,11 +166,16 @@ final class AppModel {
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…") {
do {
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 {
message = errorMessage(error)
}
@@ -163,15 +183,25 @@ final class AppModel {
reload()
}
/// Say `what` in the activity banner while `work` runs. Nested work keeps
/// the outer line once the inner one is done.
/// 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 func busy(_ what: String, _ work: () async -> Void) async {
let outer = activity
activity = what
refreshing = true
let token = nextActivity
nextActivity += 1
activities.append((token, what))
showActivity()
await work()
activity = outer
refreshing = outer != nil
activities.removeAll { $0.token == token }
showActivity()
}
private func showActivity() {
activity = activities.last?.line
refreshing = activity != nil
}
// MARK: - Shares
@@ -216,9 +246,7 @@ final class AppModel {
message = failed.joined(separator: "\n")
return
}
if metadataSettings.enabled {
await match(force: false)
}
await matchAfterCrawl()
if announce {
let added = Set(titles.map(\.key)).subtracting(before).count
message = added == 1 ? "1 title added" : "\(added) titles added"
@@ -230,34 +258,41 @@ final class AppModel {
/// offline files alone.
func repairShare(id: String, url: String, password: String?) {
Task {
do {
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
try await NativeRuntime.engine().crawl(shareId: id)
} catch {
reportError(error)
let name = shares.first { $0.id == id }?.name ?? "the share"
await busy("Reading \(name) again…") {
do {
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
try await NativeRuntime.engine().crawl(shareId: id)
} catch {
reportError(error)
}
await reloadNow()
}
reload()
await matchAfterCrawl()
}
}
func removeShare(_ id: String) {
Task {
do {
// Wait for the transfers to stop before Rust removes catalog
// and files. Rust also rejects any late publication.
await DownloadCoordinator.shared.cancelShare(id)
let store = DownloadStateStore.shared
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)
let name = shares.first { $0.id == id }?.name ?? "the share"
await busy("Removing \(name)…") {
do {
// Wait for the transfers to stop before Rust removes catalog
// and files. Rust also rejects any late publication.
await DownloadCoordinator.shared.cancelShare(id)
let store = DownloadStateStore.shared
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)
try await run { try $0.removeShare(shareId: id) }
} catch {
message = errorMessage(error)
await reloadNow()
}
reload()
}
}
@@ -326,13 +361,15 @@ final class AppModel {
func signOut() {
Task {
do {
try await NativeRuntime.engine().signOut()
} catch {
reportError(error)
await busy("Signing out…") {
do {
try await NativeRuntime.engine().signOut()
} 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
/// menu should not cost them.
func setTitleWatched(_ title: TitleRecord, _ watched: Bool) {
showWatched(title.playlist, watched)
changeWatch(title.playlist, "\(title.displayName) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in
let duration = before?.durationSecs
try engine.saveWatchState(
@@ -571,12 +609,14 @@ final class AppModel {
func removeOffline(_ file: OfflineRecord) {
Task {
do {
try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId)
} catch {
message = errorMessage(error)
await busy("Deleting the download…") {
do {
try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId)
} catch {
message = errorMessage(error)
}
await reloadNow()
}
reload()
}
}
@@ -599,14 +639,16 @@ final class AppModel {
func removeAllOffline() {
Task {
await DownloadCoordinator.shared.cancelAll()
do {
try await NativeRuntime.engine().removeAllOffline()
DownloadStateStore.shared.clear()
} catch {
reportError(error)
await busy("Deleting every download…") {
await DownloadCoordinator.shared.cancelAll()
do {
try await NativeRuntime.engine().removeAllOffline()
DownloadStateStore.shared.clear()
} 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
/// agrees with the tick.
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) {
showWatched([episode], watched)
Task {
do {
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
func reload() {
+3 -3
View File
@@ -262,7 +262,7 @@ private struct NavigationBar: View {
.padding(.bottom, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
.accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : [])
}
@@ -295,7 +295,7 @@ private struct NavigationRail: View {
.frame(width: 80)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
.accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : [])
}
@@ -350,7 +350,7 @@ private struct Snackbar: View {
Button("Undo", action: model.performUndo)
.textStyle(.labelLarge)
.foregroundStyle(scheme.inversePrimary)
.buttonStyle(.plain)
.buttonStyle(.pressable)
.padding(.horizontal, 8)
}
}
+16 -2
View File
@@ -77,6 +77,10 @@ final class PlayerHost {
private(set) var chapters = ChapterPlan(entries: [], creditsStart: nil)
/// What stopped this episode, until the viewer dismisses it.
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
/// applies to the next one.
private(set) var autoSkip = false
@@ -107,6 +111,11 @@ final class PlayerHost {
var episode: EpisodeRecord? { episodes.indices.contains(index) ? episodes[index] : nil }
var key: String? { episode.map(mediaKey) }
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 }
// MARK: - Opening
@@ -177,6 +186,7 @@ final class PlayerHost {
index = 0
minimized = false
playbackError = nil
opening = false
chapters = ChapterPlan(entries: [], creditsStart: nil)
OrientationLock.set(false)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
@@ -210,6 +220,7 @@ final class PlayerHost {
return
}
try? AVAudioSession.sharedInstance().setActive(true)
opening = true
loadTask = Task {
do {
try await session.open()
@@ -244,6 +255,8 @@ final class PlayerHost {
hardwareDecoding: SettingsStore().hardwareDecoding
)
guard loaded else { throw PlaybackFailure("libmpv rejected the stream") }
// mpv's own buffering takes over the spinner from here.
opening = false
session.transferToPlayer()
// After the load, because mpv keeps none of the three across one.
player.setVolume(global.volume)
@@ -256,6 +269,7 @@ final class PlayerHost {
abandon(session)
return
}
opening = false
playbackError = errorMessage(error)
}
}
@@ -320,7 +334,7 @@ final class PlayerHost {
}
private func tick() {
guard let player else { return }
guard let player, playingOpenEpisode else { return }
replanChapters(player)
// Auto-skip, once per chapter.
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.
var offer: SkipOffer? {
guard let player else { return nil }
guard let player, playingOpenEpisode else { return nil }
return skipOffer(plan: chapters, position: player.position)
}
+9 -6
View File
@@ -63,7 +63,8 @@ struct PlayerView: View {
}
UpNextCard(
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
)
}
@@ -73,10 +74,12 @@ struct PlayerView: View {
// Only while nothing else explains the still picture: an error
// is the more useful thing to say.
if player.stalled && host.playbackError == nil {
if (host.opening || player.stalled) && host.playbackError == nil {
VStack(spacing: 12) {
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…")
.textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
}
@@ -295,7 +298,7 @@ private struct CompactIcon: View {
.frame(width: 40, height: 40)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(PressableStyle(circle: true, wash: .white))
.disabled(!enabled)
.accessibilityLabel(label)
}
@@ -342,7 +345,7 @@ private struct UpNextCard: View {
}
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
}
.buttonStyle(.plain)
.buttonStyle(PressableStyle(wash: .white))
.accessibilityLabel("Play now")
VStack(alignment: .leading, spacing: 2) {
Text("Up next in \(remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
@@ -515,7 +518,7 @@ private struct EpisodeSheet: View {
.background(current ? scheme.secondaryContainer : .clear)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
}
}
+1 -1
View File
@@ -383,7 +383,7 @@ private struct DriveRow: View {
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
.disabled(open == nil)
}
}
+1 -1
View File
@@ -69,7 +69,7 @@ struct IconButton: View {
.frame(width: 48, height: 48)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.pressableIcon)
.foregroundStyle(enabled ? tint ?? scheme.onSurfaceVariant : scheme.onSurface.opacity(0.38))
.disabled(!enabled)
.accessibilityLabel(label)
+2 -2
View File
@@ -57,7 +57,7 @@ struct LanguageSetting: View {
Button { choosing = true } label: {
ListRow(headline: headline, supporting: languageLabel(value))
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
.sheet(isPresented: $choosing) {
LanguageSheet(headline: headline, value: value) { tag in
choosing = false
@@ -114,7 +114,7 @@ private struct LanguageSheet: View {
.padding(.vertical, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
+19 -14
View File
@@ -55,6 +55,9 @@ struct LibraryView: View {
@State private var menuFor: TitleRecord?
@State private var menuResume: Resumable?
@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
// library spent a band of every visit on something used in few of them.
@SceneStorage("library.searching") private var searching = false
@@ -90,7 +93,15 @@ struct LibraryView: View {
content(resumable)
}
.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 allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount
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
// plainly wrong — the wrong poster is what the grid shows.
MenuRow("pencil", "Change match") {
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(350))
matching = title
}
}
MenuRow("pencil", "Change match") { pendingMatch = title }
}
}
.sheet(item: $menuResume) { entry in
@@ -183,8 +189,9 @@ struct LibraryView: View {
LazyHStack(alignment: .top, spacing: 12) {
ForEach(resumable) { entry in
ContinueCard(entry: entry)
.onTapGesture { onResume(entry.title, entry.index) }
.onLongPressGesture { longPressed { menuResume = entry } }
.tapAndHold { onResume(entry.title, entry.index) } onHold: {
longPressed { menuResume = entry }
}
}
}
}
@@ -199,8 +206,7 @@ struct LibraryView: View {
ForEach(shelf.titles, id: \.key) { title in
PosterTile(title: title)
.frame(width: 112)
.onTapGesture { onTitle(title) }
.onLongPressGesture { longPressed { menuFor = title } }
.tapAndHold { onTitle(title) } onHold: { longPressed { menuFor = title } }
}
}
}
@@ -214,8 +220,7 @@ struct LibraryView: View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12, alignment: .top)], spacing: 16) {
ForEach(tiles) { tile in
PosterTile(title: tile.title, folded: tile.folded)
.onTapGesture { onTitle(tile.title) }
.onLongPressGesture { longPressed { menuFor = tile.title } }
.tapAndHold { onTitle(tile.title) } onHold: { longPressed { menuFor = tile.title } }
}
}
}
@@ -357,7 +362,7 @@ struct MenuRow: View {
} label: {
ListRow(icon: icon, headline: label)
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
.padding(.horizontal, 8)
}
}
+7 -2
View File
@@ -259,7 +259,12 @@ final class ArtworkLoader: @unchecked Sendable {
// A redirect must not leave HTTPS.
http.url?.scheme?.lowercased() == "https"
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 {
for try await byte in stream {
bytes.append(byte)
@@ -268,7 +273,7 @@ final class ArtworkLoader: @unchecked Sendable {
} catch {
return nil
}
return bytes
return Data(bytes)
}
/// 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 confirmDelete = 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?
var body: some View {
@@ -56,7 +58,7 @@ struct SettingsView: View {
Button { page = entry } label: {
ListRow(icon: entry.icon, headline: entry.title, supporting: subtitle(entry))
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
}
}
}
@@ -115,13 +117,27 @@ struct SettingsView: View {
.padding(.bottom, 24)
}
}
// Back by an edge swipe, as from a title page.
.gesture(
DragGesture(minimumDistance: 20)
.onEnded { drag in
if drag.startLocation.x < 24, drag.translation.width > 80 { self.page = nil }
}
)
.scaleEffect(1 - 0.1 * backProgress)
.clipShape(RoundedRectangle(cornerRadius: 32 * backProgress))
// Back by an edge swipe, as from a title page — from a strip of its
// own, as there. On the whole page the swipe began on whichever row
// 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 {
@@ -268,7 +284,7 @@ private struct SettingAction: View {
var body: some View {
Button(action: action) { ListRow(headline: headline, supporting: supporting, enabled: enabled) }
.buttonStyle(.plain)
.buttonStyle(.pressable)
.disabled(!enabled)
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ struct SharesView: View {
.background(scheme.primary, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.3), radius: 6, y: 3)
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
.padding(16)
}
}
+59
View File
@@ -67,6 +67,65 @@ extension ButtonStyle where Self == ProtonButtonStyle {
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.
///
/// `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.onOpenTitle = onOpenTitle
// 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))
}
@@ -231,7 +233,7 @@ struct TitleView: View {
LazyHStack(alignment: .top, spacing: 12) {
ForEach(franchise, id: \.key) { member in
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)
@@ -282,7 +284,7 @@ struct FilterChip: View {
if !selected { RoundedRectangle(cornerRadius: Corner.standard).strokeBorder(scheme.outlineVariant) }
}
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
}
}
@@ -341,7 +343,7 @@ private struct TitleAction: View {
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
}
}
@@ -449,7 +451,7 @@ private struct EpisodeRow: View {
.padding(.horizontal, 16)
.padding(.vertical, 6)
.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.
@State private var failure: String?
@State private var searched = false
@State private var forgetting = false
init(title: TitleRecord, onDone: @escaping () -> Void) {
self.title = title
@@ -510,7 +513,9 @@ struct ChangeMatchSheet: View {
.disabled(choosing)
}
} buttons: {
Button("Forget match") {
Button(forgetting ? "Forgetting…" : "Forget match") {
forgetting = true
failure = nil
Task {
do {
try await model.run { try $0.forgetMatch(titleKey: title.key) }
@@ -518,9 +523,11 @@ struct ChangeMatchSheet: View {
} catch {
failure = errorMessage(error)
}
forgetting = false
}
}
.buttonStyle(.quiet)
.disabled(forgetting || choosing)
Button("Close", action: onDone).buttonStyle(.tonal)
}
}
+1 -1
View File
@@ -14,7 +14,7 @@
# builds from the registry alone.
pkgname=proton-stream
pkgver=1.4.0
pkgver=1.4.1
pkgrel=1
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
arch=('x86_64')