mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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
This commit is contained in:
1 parent
2f3be1d193
commit
73973841c2
19 files changed
+692
-113
No files matched your search
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -177,6 +181,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 +215,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 +250,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 +264,7 @@ final class PlayerHost {
|
|||||||
abandon(session)
|
abandon(session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
opening = false
|
||||||
playbackError = errorMessage(error)
|
playbackError = errorMessage(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,10 +73,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 +297,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 +344,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 +517,7 @@ private struct EpisodeSheet: View {
|
|||||||
.background(current ? scheme.secondaryContainer : .clear)
|
.background(current ? scheme.secondaryContainer : .clear)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.pressable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 : [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user