diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index d41c63c..9450621 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -188,6 +188,12 @@ internal fun LibraryScreen( "Nothing matches", "No title in the library is called “${state.query.trim()}”.", ) + // A share just added: say it is being looked through, rather + // than offer to add the one that is already filling the page. + state.titles.isEmpty() && state.activity != null -> EmptyState( + "Looking through the share", + "${state.activity} Its titles appear here once it has been listed.", + ) state.titles.isEmpty() -> EmptyState( "Your library is empty", "Add a Proton Drive share link, and everything playable behind it shows up here.", diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index b47d925..e19ea8b 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -279,7 +279,14 @@ fun ProtonStreamApp( } Destination.Shares -> SharesScreen( state.shares, - model::addShare, + { name, url, password -> + // The first share is the library: watch it fill + // rather than stay on a form that has done its job. + val first = state.titles.isEmpty() + model.addShare(name, url, password).also { refusal -> + if (refusal == null && first) destination = Destination.Library + } + }, model::repairShare, model::refreshShare, model::removeShare, diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 489e82d..d046f41 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -144,6 +144,8 @@ pub enum Action { PasteShare(String), /// Go to the add form, caret in the link field. OpenShareForm, + /// Stop offering posters and synopses on the library page. + DismissMetadataOffer, /// Store a new link, and password, for a share already in the library. RepairShare { id: String, @@ -813,12 +815,25 @@ impl App { Event::ShareAdded(name) => { self.form = ShareForm::default(); self.note(ctx, format!("added {name}"), false); + // The first share is the library: watch it fill rather than + // stay on a form that has done its job. A second one is + // more likely one of several being added in a row. + if self.library.is_empty() && self.page == Page::Shares { + self.page = Page::Library; + } } - Event::ShareRejected(error) => { + Event::ShareRejected { + error, + needs_password, + } => { // Nothing was crawled, so nothing will say it finished. self.crawling = false; self.form.sending = false; self.form.error = Some(error); + // The field the refusal asks for, opened and ready. + if needs_password { + self.form.has_password = true; + } } Event::Connected { failures } => { self.connecting = false; @@ -1645,6 +1660,11 @@ impl App { self.page = Page::Shares; self.form.focus = true; } + Action::DismissMetadataOffer => { + let mut prefs = self.engine.desktop_prefs(); + prefs.metadata_offer_dismissed = true; + self.engine.set_desktop_prefs(prefs); + } Action::AddShare { name, url, @@ -2221,6 +2241,7 @@ impl eframe::App for App { }, ); + let crawl = self.crawling.then(|| crawl_label(&self.crawl_progress)); let App { engine, page, @@ -2320,6 +2341,9 @@ impl eframe::App for App { loaded: *loaded, search, tiles: engine.appearance().tiles, + crawl: crawl.as_deref(), + offer_metadata: !settings.enabled + && !engine.desktop_prefs().metadata_offer_dismissed, }, &mut actions, ), diff --git a/crates/pstr-app/src/desktop_prefs.rs b/crates/pstr-app/src/desktop_prefs.rs index 1fb2818..5f1e807 100644 --- a/crates/pstr-app/src/desktop_prefs.rs +++ b/crates/pstr-app/src/desktop_prefs.rs @@ -25,6 +25,8 @@ pub struct DesktopPrefs { pub filter: crate::app::Filter, pub sort: pstr_core::browse::Sort, pub grouped: bool, + /// The library's offer of posters and synopses was turned down. + pub metadata_offer_dismissed: bool, } impl Default for DesktopPrefs { @@ -37,6 +39,7 @@ impl Default for DesktopPrefs { // On: a share holding a series and its three films is four tiles // of the same picture otherwise. grouped: true, + metadata_offer_dismissed: false, } } } diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index 9eacb33..8890a50 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -177,7 +177,11 @@ pub enum Event { /// The share the form asked for is stored; its crawl is starting. ShareAdded(String), /// The share the form asked for was refused, with why. Nothing was crawled. - ShareRejected(String), + ShareRejected { + error: String, + /// The link has a password of its own and none was given. + needs_password: bool, + }, /// Shares opened; playback is possible from here. Connected { /// Shares that did not open, with why. @@ -305,6 +309,16 @@ pub enum Event { BrowseFailed(String), } +impl Event { + /// The add form's refusal of `error`. + fn rejected(error: &pstr_core::Error) -> Self { + Self::ShareRejected { + error: error.to_string(), + needs_password: matches!(error, pstr_core::Error::PasswordNeeded), + } + } +} + /// Where the Proton account stands. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum AccountStatus { @@ -671,7 +685,7 @@ impl Engine { .await { Ok((share, _)) => share, - Err(error) => return engine.emit(Event::ShareRejected(error.to_string())), + Err(error) => return engine.emit(Event::rejected(&error)), }; engine.emit(Event::ShareAdded(share.name.clone())); engine.load_shares(); @@ -720,7 +734,7 @@ impl Engine { }; let share = match engine.store.add_folder(&name, folder) { Ok(share) => share, - Err(error) => return engine.emit(Event::ShareRejected(error.to_string())), + Err(error) => return engine.emit(Event::rejected(&error)), }; engine.emit(Event::ShareAdded(share.name.clone())); engine.load_shares(); diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index b7bc31a..7501dd4 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -19,6 +19,11 @@ pub struct Shelves<'a> { pub search: &'a str, /// The grid's shape. pub tiles: Tiles, + /// What a crawl in progress has found, if one is. + pub crawl: Option<&'a str>, + /// Whether to offer posters and synopses: they are off, and the offer has + /// not been turned down. + pub offer_metadata: bool, } pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec) { @@ -28,12 +33,14 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: loaded, search, tiles, + crawl, + offer_metadata, } = shelves; if !loaded { return placeholder(ui); } if library.is_empty() { - return empty_state(ui, actions); + return empty_state(ui, crawl, actions); } let pick = |indices: &[usize]| -> Vec<&Title> { @@ -48,6 +55,10 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { + if !searching && offer_metadata { + metadata_offer(ui, actions); + ui.add_space(theme::space::XL); + } if !searching { if let Some(title) = featured(library, view, art.metadata, today()) { hero(ui, art, title, actions); @@ -635,9 +646,54 @@ fn placeholder(ui: &mut egui::Ui) { } } -fn empty_state(ui: &mut egui::Ui, actions: &mut Vec) { +/// Posters and synopses, offered once there is a library to put them on and +/// never turned on unasked: switching them on sends title names out. +fn metadata_offer(ui: &mut egui::Ui, actions: &mut Vec) { + egui::Frame::new() + .fill(theme::card()) + .corner_radius(egui::CornerRadius::same(theme::radius::MD)) + .inner_margin(egui::Margin::symmetric(14, 12)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label(egui::RichText::new("Add posters and synopses?").strong()); + ui.label(ui::muted( + "Turning it on in Settings sends the titles in your library — not your \ + files, and nothing about what you have watched — to AniList or TMDB.", + )); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Not now").clicked() { + actions.push(Action::DismissMetadataOffer); + } + if ui::accent_button(ui, "Open settings").clicked() { + actions.push(Action::Goto(Page::Settings)); + } + }); + }); + }); +} + +fn empty_state(ui: &mut egui::Ui, crawl: Option<&str>, actions: &mut Vec) { ui.vertical_centered(|ui| { ui.add_space(120.0); + // A share just added: say it is being looked through, rather than + // offer to add the one that is already filling the page. + if let Some(crawl) = crawl { + ui.add(egui::Spinner::new().size(28.0)); + ui.add_space(theme::space::L); + ui.label( + theme::Role::Title + .rich("Looking through the share") + .strong(), + ); + ui.add_space(theme::space::S); + ui.label(ui::muted(format!( + "Its titles appear here once it has been listed ({crawl})." + ))); + return; + } ui.label( theme::Role::Title .rich("Nothing in the library yet") diff --git a/crates/pstr-core/src/error.rs b/crates/pstr-core/src/error.rs index e704c3c..a643eff 100644 --- a/crates/pstr-core/src/error.rs +++ b/crates/pstr-core/src/error.rs @@ -46,6 +46,11 @@ pub enum Error { /// mailbox password. #[error("{0}")] SignIn(String), + + /// A public link with a password of its own was opened without one. The + /// handshake refusal says nothing a viewer can act on; this does. + #[error("This link has a password of its own. Enter it to add the link.")] + PasswordNeeded, } impl Error { diff --git a/crates/pstr-core/src/shares.rs b/crates/pstr-core/src/shares.rs index 96255e0..6339810 100644 --- a/crates/pstr-core/src/shares.rs +++ b/crates/pstr-core/src/shares.rs @@ -443,7 +443,22 @@ impl ShareStore { )); } let custom_password = custom_password.filter(|password| !password.is_empty()); - let client = open_link(url, custom_password).await?; + let client = match open_link(url, custom_password).await { + Ok(client) => client, + // Asked only after a refusal: the link's public flags cost a round + // trip, and most links open without one. + Err(error) if custom_password.is_none() => { + let protected = ProtonDrivePublicLinkClient::info(client_configuration(), url) + .await + .is_ok_and(|info| info.is_custom_password_protected); + return Err(if protected { + Error::PasswordNeeded + } else { + error + }); + } + Err(error) => return Err(error), + }; // A name is the viewer's to give, not theirs to invent before they // can see anything: without one, the shared folder's own name. let name = match name.trim() { diff --git a/ios/ProtonStream/App/ProtonStreamApp.swift b/ios/ProtonStream/App/ProtonStreamApp.swift index ed82a1b..ef0a167 100644 --- a/ios/ProtonStream/App/ProtonStreamApp.swift +++ b/ios/ProtonStream/App/ProtonStreamApp.swift @@ -181,7 +181,9 @@ struct RootView: View { selectedTitleKey = title.key }) case .shares: - SharesView(incomingLink: $incomingLink) + // The first share is the library: watch it fill rather than stay + // on a form that has done its job. + SharesView(incomingLink: $incomingLink, onFirstShareAdded: { select(.library) }) case .downloads: DownloadsView() case .settings: diff --git a/ios/ProtonStream/UI/LibraryView.swift b/ios/ProtonStream/UI/LibraryView.swift index c01a008..3807aa9 100644 --- a/ios/ProtonStream/UI/LibraryView.swift +++ b/ios/ProtonStream/UI/LibraryView.swift @@ -145,6 +145,10 @@ struct LibraryView: View { LibrarySkeleton() } else if model.titles.isEmpty && !model.query.trimmingCharacters(in: .whitespaces).isEmpty { EmptyState("Nothing matches", "No title in the library is called “\(model.query.trimmingCharacters(in: .whitespaces))”.") + } else if model.titles.isEmpty, let activity = model.activity { + // A share just added: say it is being looked through, rather than + // offer to add the one that is already filling the page. + EmptyState("Looking through the share", "\(activity) Its titles appear here once it has been listed.") } else if model.titles.isEmpty { ScrollView { EmptyState( diff --git a/ios/ProtonStream/UI/SharesView.swift b/ios/ProtonStream/UI/SharesView.swift index 8e19551..0f52d5e 100644 --- a/ios/ProtonStream/UI/SharesView.swift +++ b/ios/ProtonStream/UI/SharesView.swift @@ -6,6 +6,7 @@ struct SharesView: View { @Environment(\.scheme) private var scheme /// A link opened in this app: the Add form opens with it filled in. @Binding var incomingLink: String? + var onFirstShareAdded: () -> Void = {} @State private var showAdd = false @State private var signingIn = false @@ -57,7 +58,10 @@ struct SharesView: View { } .sheet(isPresented: Binding(get: { showAdd || incomingLink != nil }, set: { if !$0 { showAdd = false; incomingLink = nil } })) { AddShareSheet(initialUrl: incomingLink ?? "") { name, url, password in - await model.addShare(name: name, url: url, password: password) + let first = model.titles.isEmpty + let refusal = await model.addShare(name: name, url: url, password: password) + if refusal == nil && first { onFirstShareAdded() } + return refusal } } .sheet(isPresented: Binding(