diff --git a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt index b3f3e83..48e1fe8 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -19,7 +20,10 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import io.narl.protonstream.download.RetainedDownload +import io.narl.protonstream.ui.theme.AccentButton import uniffi.pstr_android.EpisodeRecord /** @@ -93,11 +97,20 @@ internal fun Modifier.sharedArt(key: String): Modifier { } @Composable -internal fun EmptyState(title: String, body: String) { +internal fun EmptyState(title: String, body: String, action: Pair Unit>? = null) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(title, style = MaterialTheme.typography.titleMedium) - Text(body, style = MaterialTheme.typography.bodyMedium) + Column(Modifier.padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center) + Text( + body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp), + ) + action?.let { (label, onClick) -> + AccentButton(onClick = onClick, modifier = Modifier.padding(top = 16.dp)) { Text(label) } + } } } } 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 28fea5d..d41c63c 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 @@ -124,6 +124,7 @@ internal fun LibraryScreen( onMatchChanged: () -> Unit = {}, onError: (Throwable) -> Unit = {}, onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> }, + onAddShare: () -> Unit = {}, ) { val context = LocalContext.current // What a long press opened a menu for: a title, or a Continue watching card. @@ -189,7 +190,8 @@ internal fun LibraryScreen( ) state.titles.isEmpty() -> EmptyState( "Your library is empty", - "Add a Proton Drive public link under Shares, then refresh.", + "Add a Proton Drive share link, and everything playable behind it shows up here.", + action = "Add a share" to onAddShare, ) else -> LibraryGrid( state, 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 2c90e9b..9b275d1 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 @@ -133,6 +133,7 @@ fun ProtonStreamApp( // is what says so. Saved, because a rotation must not throw the viewer back // into the video they had just left. var playerMinimized by rememberSaveable { mutableStateOf(false) } + var addingShare by remember { mutableStateOf(false) } val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey } val playing = playingTitleKey @@ -226,6 +227,10 @@ fun ProtonStreamApp( onMatchChanged = model::reloadAfterMetadataChange, onError = model::reportError, onArrange = model::arrange, + onAddShare = { + destination = Destination.Shares + addingShare = true + }, ) } val titlePane = @Composable { selected: TitleRecord -> @@ -277,8 +282,10 @@ fun ProtonStreamApp( model::repairShare, model::refreshShare, model::removeShare, - shareLink, - onShareLinkTaken, + // The empty library's button opens the form as a + // pasted link would, only with nothing in it. + shareLink ?: "".takeIf { addingShare }, + { addingShare = false; onShareLinkTaken() }, body, account = state.account, accountActions = AccountActions( diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt index 1abad13..0f266c6 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.KeyboardType @@ -51,6 +52,8 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.FolderShared +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.ui.graphics.Color @Composable @@ -330,6 +333,7 @@ private fun AddShareDialog( ) { var name by remember { mutableStateOf("") } var url by remember(initialUrl) { mutableStateOf(initialUrl) } + var showLink by remember { mutableStateOf(false) } var password by remember { mutableStateOf("") } var adding by remember { mutableStateOf(false) } var refusal by remember { mutableStateOf(null) } @@ -340,17 +344,31 @@ private fun AddShareDialog( title = { Text("Add Proton Drive share") }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true) OutlinedTextField( url, { url = it }, - label = { Text("Public share URL") }, + label = { Text("Share link") }, singleLine = true, // The URL fragment is a secret. Password input disables // IME learning/suggestions while normal long-press paste - // remains available. - visualTransformation = PasswordVisualTransformation(), + // remains available; the eye shows what was pasted. + visualTransformation = if (showLink) VisualTransformation.None else PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { showLink = !showLink }) { + Icon( + if (showLink) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showLink) "Hide link" else "Show link", + ) + } + }, + ) + OutlinedTextField( + name, + { name = it }, + label = { Text("Name (optional)") }, + supportingText = { Text("Defaults to the shared folder's name") }, + singleLine = true, ) OutlinedTextField( password, @@ -381,7 +399,7 @@ private fun AddShareDialog( if (refusal == null) onDismiss() } }, - enabled = name.isNotBlank() && url.isNotBlank() && !adding, + enabled = url.isNotBlank() && !adding, ) { Text(if (adding) "Adding…" else "Add") } }, dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { Text("Cancel") } }, diff --git a/android/app/src/test/screenshots/downloads-empty.png b/android/app/src/test/screenshots/downloads-empty.png index 823b4e1..17ff516 100644 Binary files a/android/app/src/test/screenshots/downloads-empty.png and b/android/app/src/test/screenshots/downloads-empty.png differ diff --git a/android/app/src/test/screenshots/library-empty.png b/android/app/src/test/screenshots/library-empty.png index 7db0f8a..8bb6594 100644 Binary files a/android/app/src/test/screenshots/library-empty.png and b/android/app/src/test/screenshots/library-empty.png differ diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index cc0f624..afbb972 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -126,6 +126,8 @@ pub enum Action { Screenshot, /// A share link was pasted outside the form: open the form with it. PasteShare(String), + /// Go to the add form, caret in the link field. + OpenShareForm, AddShare { name: String, url: String, @@ -524,6 +526,8 @@ pub struct ShareForm { pub sending: bool, /// Why the last one was refused. pub error: Option, + /// Put the caret in the link field on the next frame the form is drawn. + pub focus: bool, } pub struct App { @@ -1512,7 +1516,11 @@ impl App { self.page = Page::Shares; self.form.url = url; self.form.error = None; - self.note(ctx, "share link pasted — name it and add it", false); + self.note(ctx, "share link pasted — add it, or name it first", false); + } + Action::OpenShareForm => { + self.page = Page::Shares; + self.form.focus = true; } Action::AddShare { name, diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index 13c0dec..b7bc31a 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -649,7 +649,7 @@ fn empty_state(ui: &mut egui::Ui, actions: &mut Vec) { )); ui.add_space(theme::space::XL); if ui::accent_button(ui, "Add a share").clicked() { - actions.push(Action::Goto(Page::Shares)); + actions.push(Action::OpenShareForm); } }); } diff --git a/crates/pstr-app/src/ui/shares.rs b/crates/pstr-app/src/ui/shares.rs index 8f1419a..91487ae 100644 --- a/crates/pstr-app/src/ui/shares.rs +++ b/crates/pstr-app/src/ui/shares.rs @@ -113,6 +113,15 @@ fn share_row( ui.add_space(theme::space::S); } +/// The add form's link field, which the library's empty state focuses. +pub fn link_field_id() -> egui::Id { + egui::Id::new("share-link") +} + +fn ui_enter_pressed(ctx: &egui::Context) -> bool { + ctx.input(|input| input.key_pressed(egui::Key::Enter)) +} + fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec) { ui::section(ui, "Add a share"); ui.label(ui::muted( @@ -128,24 +137,28 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: .show(ui, |ui| { ui.set_width(ui.available_width().min(640.0)); - ui.label(ui::muted("Name")); - ui.add( - egui::TextEdit::singleline(&mut form.name) - .hint_text("Anime") - .desired_width(f32::INFINITY), - ); - ui.add_space(theme::space::M); + // Enter in any field of the form submits it, as the sign-in + // form's do. + let mut entered = false; + let mut submitted_from = |response: egui::Response| { + entered |= response.lost_focus() && ui_enter_pressed(&response.ctx); + }; ui.label(ui::muted("Link")); ui.horizontal(|ui| { let paste = 60.0; - let field = egui::Id::new("share-link"); - ui.add( - egui::TextEdit::singleline(&mut form.url) - .id(field) - .password(true) - .hint_text("https://drive.proton.me/urls/…#…") - .desired_width(ui.available_width() - paste), + let field = link_field_id(); + if std::mem::take(&mut form.focus) { + ui.memory_mut(|memory| memory.request_focus(field)); + } + submitted_from( + ui.add( + egui::TextEdit::singleline(&mut form.url) + .id(field) + .password(true) + .hint_text("https://drive.proton.me/urls/…#…") + .desired_width(ui.available_width() - paste), + ), ); // The link is masked, so pasting is the only sensible way in — // and a button says that more plainly than the field does. @@ -170,20 +183,31 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: } ui.add_space(theme::space::M); + ui.label(ui::muted("Name (optional)")); + submitted_from( + ui.add( + egui::TextEdit::singleline(&mut form.name) + .hint_text("The shared folder's own name") + .desired_width(f32::INFINITY), + ), + ); + ui.add_space(theme::space::M); + ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password"); if form.has_password { ui.add_space(theme::space::S); - ui.add( - egui::TextEdit::singleline(&mut form.password) - .password(true) - .hint_text("Link password") - .desired_width(f32::INFINITY), + submitted_from( + ui.add( + egui::TextEdit::singleline(&mut form.password) + .password(true) + .hint_text("Link password") + .desired_width(f32::INFINITY), + ), ); } ui.add_space(theme::space::L); - let ready = !form.name.trim().is_empty() - && !form.url.trim().is_empty() + let ready = !form.url.trim().is_empty() && problem.is_none() && (!form.has_password || !form.password.is_empty()) && !form.sending; @@ -193,11 +217,11 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: ui.add_space(theme::space::S); } ui.horizontal(|ui| { - if ui - .add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl")) + let clicked = ui + .add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add share")) .inner - .clicked() - { + .clicked(); + if clicked || (ready && entered) { actions.push(Action::AddShare { name: form.name.trim().to_string(), url: form.url.trim().to_string(), @@ -210,8 +234,8 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: if form.sending { ui.add(egui::Spinner::new().size(14.0)); ui.label(ui::muted("adding…")); - } else if form.name.trim().is_empty() || form.url.trim().is_empty() { - ui.label(ui::muted("A name and a link are needed.")); + } else if form.url.trim().is_empty() { + ui.label(ui::muted("Paste a share link to add it.")); } }); }); diff --git a/crates/pstr-core/src/shares.rs b/crates/pstr-core/src/shares.rs index 1515c87..96255e0 100644 --- a/crates/pstr-core/src/shares.rs +++ b/crates/pstr-core/src/shares.rs @@ -422,7 +422,7 @@ impl ShareStore { } /// [`Self::add`], but only once the link has been opened with the - /// password given. + /// password given. An empty `name` takes the shared folder's own. /// /// A link that is mistyped, expired or behind the wrong password used to /// be stored anyway, so the first try failed on the crawl and every retry @@ -444,7 +444,19 @@ impl ShareStore { } let custom_password = custom_password.filter(|password| !password.is_empty()); let client = open_link(url, custom_password).await?; - let share = self.add(name, url, custom_password)?; + // 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() { + "" => client + .root_node() + .await + .ok() + .map(|root| root.name) + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| "Shared folder".to_owned()), + given => given.to_owned(), + }; + let share = self.add(&name, url, custom_password)?; Ok((share, client)) } diff --git a/ios/ProtonStream/App/ProtonStreamApp.swift b/ios/ProtonStream/App/ProtonStreamApp.swift index ea79371..ed82a1b 100644 --- a/ios/ProtonStream/App/ProtonStreamApp.swift +++ b/ios/ProtonStream/App/ProtonStreamApp.swift @@ -194,7 +194,15 @@ struct RootView: View { /// tap rather than back and in again. @ViewBuilder private func libraryPage(width: CGFloat) -> some View { let selected = selectedTitleKey.flatMap { key in model.titles.first { $0.key == key } } - let library = LibraryView(onResume: play, onTitle: { title in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } }) + let library = LibraryView( + onResume: play, + onTitle: { title in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } }, + // The form as a pasted link opens it, only with nothing in it. + onAddShare: { + incomingLink = "" + destination = .shares + } + ) if width >= twoPaneWidth { HStack(spacing: 0) { library.frame(width: width * 0.45) diff --git a/ios/ProtonStream/UI/Common.swift b/ios/ProtonStream/UI/Common.swift index 75b01ee..38292db 100644 --- a/ios/ProtonStream/UI/Common.swift +++ b/ios/ProtonStream/UI/Common.swift @@ -79,17 +79,22 @@ struct IconButton: View { struct EmptyState: View { let title: String let body_: String + let action: (label: String, run: () -> Void)? @Environment(\.scheme) private var scheme - init(_ title: String, _ body: String) { + init(_ title: String, _ body: String, action: (label: String, run: () -> Void)? = nil) { self.title = title body_ = body + self.action = action } var body: some View { VStack(spacing: 2) { Text(title).textStyle(.titleMedium).foregroundStyle(scheme.onSurface) - Text(body_).textStyle(.bodyMedium).foregroundStyle(scheme.onSurface) + Text(body_).textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant) + if let action { + Button(action.label, action: action.run).buttonStyle(.accent).padding(.top, 16) + } } .multilineTextAlignment(.center) .padding(24) diff --git a/ios/ProtonStream/UI/LibraryView.swift b/ios/ProtonStream/UI/LibraryView.swift index 7753d13..c01a008 100644 --- a/ios/ProtonStream/UI/LibraryView.swift +++ b/ios/ProtonStream/UI/LibraryView.swift @@ -57,6 +57,7 @@ struct LibraryView: View { @Environment(\.scheme) private var scheme let onResume: (TitleRecord, Int) -> Void let onTitle: (TitleRecord) -> Void + var onAddShare: () -> Void = {} // What a long press opened a menu for: a title, or a Continue watching card. @State private var menuFor: TitleRecord? @@ -146,7 +147,11 @@ struct LibraryView: View { EmptyState("Nothing matches", "No title in the library is called “\(model.query.trimmingCharacters(in: .whitespaces))”.") } else if model.titles.isEmpty { ScrollView { - EmptyState("Your library is empty", "Add a Proton Drive public link under Shares, then refresh.") + EmptyState( + "Your library is empty", + "Add a Proton Drive share link, and everything playable behind it shows up here.", + action: ("Add a share", onAddShare) + ) .containerRelativeFrame(.vertical) } .refreshable { await model.crawlNow(nil) } diff --git a/ios/ProtonStream/UI/SharesView.swift b/ios/ProtonStream/UI/SharesView.swift index 064909b..8e19551 100644 --- a/ios/ProtonStream/UI/SharesView.swift +++ b/ios/ProtonStream/UI/SharesView.swift @@ -211,15 +211,14 @@ private struct AddShareSheet: View { } private var ready: Bool { - !name.trimmingCharacters(in: .whitespaces).isEmpty && !url.trimmingCharacters(in: .whitespaces).isEmpty + !url.trimmingCharacters(in: .whitespaces).isEmpty } var body: some View { DialogSheet("Add Proton Drive share") { - Field(label: "Library name", text: $name) // The URL fragment is a secret, so the field is a secure one: no // keyboard learning, no suggestions, and paste still works. - Field(label: "Public share URL", text: $url, secret: true) + Field(label: "Share link", text: $url, secret: true) // There is no share sheet into a sideloaded app, so a link copied // elsewhere comes in from the clipboard. if url.isEmpty { @@ -230,6 +229,8 @@ private struct AddShareSheet: View { } .buttonStyle(.quiet) } + // Without one, the shared folder's own name. + Field(label: "Name (optional)", text: $name) Field(label: "Custom password (optional)", text: $password, secret: true) if adding { HStack(spacing: 12) {