mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(core,app,android,ios): finish adding a first share where it pays off
- A link with a password of its own, added without one, now fails as "This link has a password of its own" instead of the handshake's refusal. The link's public flags are only asked for after a refusal. The desktop form turns its password toggle on. - After the first share is added, all three clients go to the library. While that share is being listed, the empty library says so instead of offering to add a share. - The desktop library offers posters and synopses once there is a library to put them on. The offer says what is sent, links to Settings, never turns anything on, and "Not now" is remembered.
This commit is contained in:
1 parent
0f96cc4514
commit
89e55aac12
11 files changed
+150
-10
No files matched your search
@@ -188,6 +188,12 @@ internal fun LibraryScreen(
|
|||||||
"Nothing matches",
|
"Nothing matches",
|
||||||
"No title in the library is called “${state.query.trim()}”.",
|
"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(
|
state.titles.isEmpty() -> EmptyState(
|
||||||
"Your library is empty",
|
"Your library is empty",
|
||||||
"Add a Proton Drive share link, and everything playable behind it shows up here.",
|
"Add a Proton Drive share link, and everything playable behind it shows up here.",
|
||||||
|
|||||||
@@ -279,7 +279,14 @@ fun ProtonStreamApp(
|
|||||||
}
|
}
|
||||||
Destination.Shares -> SharesScreen(
|
Destination.Shares -> SharesScreen(
|
||||||
state.shares,
|
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::repairShare,
|
||||||
model::refreshShare,
|
model::refreshShare,
|
||||||
model::removeShare,
|
model::removeShare,
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ pub enum Action {
|
|||||||
PasteShare(String),
|
PasteShare(String),
|
||||||
/// Go to the add form, caret in the link field.
|
/// Go to the add form, caret in the link field.
|
||||||
OpenShareForm,
|
OpenShareForm,
|
||||||
|
/// Stop offering posters and synopses on the library page.
|
||||||
|
DismissMetadataOffer,
|
||||||
/// Store a new link, and password, for a share already in the library.
|
/// Store a new link, and password, for a share already in the library.
|
||||||
RepairShare {
|
RepairShare {
|
||||||
id: String,
|
id: String,
|
||||||
@@ -813,12 +815,25 @@ impl App {
|
|||||||
Event::ShareAdded(name) => {
|
Event::ShareAdded(name) => {
|
||||||
self.form = ShareForm::default();
|
self.form = ShareForm::default();
|
||||||
self.note(ctx, format!("added {name}"), false);
|
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.
|
// Nothing was crawled, so nothing will say it finished.
|
||||||
self.crawling = false;
|
self.crawling = false;
|
||||||
self.form.sending = false;
|
self.form.sending = false;
|
||||||
self.form.error = Some(error);
|
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 } => {
|
Event::Connected { failures } => {
|
||||||
self.connecting = false;
|
self.connecting = false;
|
||||||
@@ -1645,6 +1660,11 @@ impl App {
|
|||||||
self.page = Page::Shares;
|
self.page = Page::Shares;
|
||||||
self.form.focus = true;
|
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 {
|
Action::AddShare {
|
||||||
name,
|
name,
|
||||||
url,
|
url,
|
||||||
@@ -2221,6 +2241,7 @@ impl eframe::App for App {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let crawl = self.crawling.then(|| crawl_label(&self.crawl_progress));
|
||||||
let App {
|
let App {
|
||||||
engine,
|
engine,
|
||||||
page,
|
page,
|
||||||
@@ -2320,6 +2341,9 @@ impl eframe::App for App {
|
|||||||
loaded: *loaded,
|
loaded: *loaded,
|
||||||
search,
|
search,
|
||||||
tiles: engine.appearance().tiles,
|
tiles: engine.appearance().tiles,
|
||||||
|
crawl: crawl.as_deref(),
|
||||||
|
offer_metadata: !settings.enabled
|
||||||
|
&& !engine.desktop_prefs().metadata_offer_dismissed,
|
||||||
},
|
},
|
||||||
&mut actions,
|
&mut actions,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ pub struct DesktopPrefs {
|
|||||||
pub filter: crate::app::Filter,
|
pub filter: crate::app::Filter,
|
||||||
pub sort: pstr_core::browse::Sort,
|
pub sort: pstr_core::browse::Sort,
|
||||||
pub grouped: bool,
|
pub grouped: bool,
|
||||||
|
/// The library's offer of posters and synopses was turned down.
|
||||||
|
pub metadata_offer_dismissed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DesktopPrefs {
|
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
|
// On: a share holding a series and its three films is four tiles
|
||||||
// of the same picture otherwise.
|
// of the same picture otherwise.
|
||||||
grouped: true,
|
grouped: true,
|
||||||
|
metadata_offer_dismissed: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,7 +177,11 @@ pub enum Event {
|
|||||||
/// The share the form asked for is stored; its crawl is starting.
|
/// The share the form asked for is stored; its crawl is starting.
|
||||||
ShareAdded(String),
|
ShareAdded(String),
|
||||||
/// The share the form asked for was refused, with why. Nothing was crawled.
|
/// 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.
|
/// Shares opened; playback is possible from here.
|
||||||
Connected {
|
Connected {
|
||||||
/// Shares that did not open, with why.
|
/// Shares that did not open, with why.
|
||||||
@@ -305,6 +309,16 @@ pub enum Event {
|
|||||||
BrowseFailed(String),
|
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.
|
/// Where the Proton account stands.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub enum AccountStatus {
|
pub enum AccountStatus {
|
||||||
@@ -671,7 +685,7 @@ impl Engine {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((share, _)) => share,
|
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.emit(Event::ShareAdded(share.name.clone()));
|
||||||
engine.load_shares();
|
engine.load_shares();
|
||||||
@@ -720,7 +734,7 @@ impl Engine {
|
|||||||
};
|
};
|
||||||
let share = match engine.store.add_folder(&name, folder) {
|
let share = match engine.store.add_folder(&name, folder) {
|
||||||
Ok(share) => share,
|
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.emit(Event::ShareAdded(share.name.clone()));
|
||||||
engine.load_shares();
|
engine.load_shares();
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ pub struct Shelves<'a> {
|
|||||||
pub search: &'a str,
|
pub search: &'a str,
|
||||||
/// The grid's shape.
|
/// The grid's shape.
|
||||||
pub tiles: Tiles,
|
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<Action>) {
|
pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec<Action>) {
|
||||||
@@ -28,12 +33,14 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
|||||||
loaded,
|
loaded,
|
||||||
search,
|
search,
|
||||||
tiles,
|
tiles,
|
||||||
|
crawl,
|
||||||
|
offer_metadata,
|
||||||
} = shelves;
|
} = shelves;
|
||||||
if !loaded {
|
if !loaded {
|
||||||
return placeholder(ui);
|
return placeholder(ui);
|
||||||
}
|
}
|
||||||
if library.is_empty() {
|
if library.is_empty() {
|
||||||
return empty_state(ui, actions);
|
return empty_state(ui, crawl, actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
let pick = |indices: &[usize]| -> Vec<&Title> {
|
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()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
|
if !searching && offer_metadata {
|
||||||
|
metadata_offer(ui, actions);
|
||||||
|
ui.add_space(theme::space::XL);
|
||||||
|
}
|
||||||
if !searching {
|
if !searching {
|
||||||
if let Some(title) = featured(library, view, art.metadata, today()) {
|
if let Some(title) = featured(library, view, art.metadata, today()) {
|
||||||
hero(ui, art, title, actions);
|
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<Action>) {
|
/// 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<Action>) {
|
||||||
|
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<Action>) {
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
ui.add_space(120.0);
|
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(
|
ui.label(
|
||||||
theme::Role::Title
|
theme::Role::Title
|
||||||
.rich("Nothing in the library yet")
|
.rich("Nothing in the library yet")
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ pub enum Error {
|
|||||||
/// mailbox password.
|
/// mailbox password.
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
SignIn(String),
|
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 {
|
impl Error {
|
||||||
|
|||||||
@@ -443,7 +443,22 @@ impl ShareStore {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let custom_password = custom_password.filter(|password| !password.is_empty());
|
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
|
// 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.
|
// can see anything: without one, the shared folder's own name.
|
||||||
let name = match name.trim() {
|
let name = match name.trim() {
|
||||||
|
|||||||
@@ -181,7 +181,9 @@ struct RootView: View {
|
|||||||
selectedTitleKey = title.key
|
selectedTitleKey = title.key
|
||||||
})
|
})
|
||||||
case .shares:
|
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:
|
case .downloads:
|
||||||
DownloadsView()
|
DownloadsView()
|
||||||
case .settings:
|
case .settings:
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ struct LibraryView: View {
|
|||||||
LibrarySkeleton()
|
LibrarySkeleton()
|
||||||
} else if model.titles.isEmpty && !model.query.trimmingCharacters(in: .whitespaces).isEmpty {
|
} 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))”.")
|
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 {
|
} else if model.titles.isEmpty {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct SharesView: View {
|
|||||||
@Environment(\.scheme) private var scheme
|
@Environment(\.scheme) private var scheme
|
||||||
/// A link opened in this app: the Add form opens with it filled in.
|
/// A link opened in this app: the Add form opens with it filled in.
|
||||||
@Binding var incomingLink: String?
|
@Binding var incomingLink: String?
|
||||||
|
var onFirstShareAdded: () -> Void = {}
|
||||||
|
|
||||||
@State private var showAdd = false
|
@State private var showAdd = false
|
||||||
@State private var signingIn = 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 } })) {
|
.sheet(isPresented: Binding(get: { showAdd || incomingLink != nil }, set: { if !$0 { showAdd = false; incomingLink = nil } })) {
|
||||||
AddShareSheet(initialUrl: incomingLink ?? "") { name, url, password in
|
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(
|
.sheet(isPresented: Binding(
|
||||||
|
|||||||
Reference in new issue
Block a user