mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(core,app,android,ios): add a first share without a detour or a name to invent
The empty library said to add a link "under Shares, then refresh" with nothing to press on the phones, and on the desktop only switched page. All three now open the add form from there, the desktop with the caret in the link field; and adding already crawls, so there is nothing to refresh. A share's name was required before the viewer had seen anything to name. It is optional now: without one, ShareStore::add_verified takes the shared folder's own name from the link it has just opened. The link comes first in each form. The desktop form submits on Enter, and the Android link field has an eye to check what was pasted.
This commit is contained in:
1 parent
5807493a4a
commit
603cc0c1a9
14 files changed
+153
-50
No files matched your search
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -19,7 +20,10 @@ import androidx.compose.runtime.staticCompositionLocalOf
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
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.download.RetainedDownload
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,11 +97,20 @@ internal fun Modifier.sharedArt(key: String): Modifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun EmptyState(title: String, body: String) {
|
internal fun EmptyState(title: String, body: String, action: Pair<String, () -> Unit>? = null) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(Modifier.padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
|
||||||
Text(body, style = MaterialTheme.typography.bodyMedium)
|
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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ internal fun LibraryScreen(
|
|||||||
onMatchChanged: () -> Unit = {},
|
onMatchChanged: () -> Unit = {},
|
||||||
onError: (Throwable) -> Unit = {},
|
onError: (Throwable) -> Unit = {},
|
||||||
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
|
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
|
||||||
|
onAddShare: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
// What a long press opened a menu for: a title, or a Continue watching card.
|
// 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(
|
state.titles.isEmpty() -> EmptyState(
|
||||||
"Your library is empty",
|
"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(
|
else -> LibraryGrid(
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ fun ProtonStreamApp(
|
|||||||
// is what says so. Saved, because a rotation must not throw the viewer back
|
// is what says so. Saved, because a rotation must not throw the viewer back
|
||||||
// into the video they had just left.
|
// into the video they had just left.
|
||||||
var playerMinimized by rememberSaveable { mutableStateOf(false) }
|
var playerMinimized by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var addingShare by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
|
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
|
||||||
val playing = playingTitleKey
|
val playing = playingTitleKey
|
||||||
@@ -226,6 +227,10 @@ fun ProtonStreamApp(
|
|||||||
onMatchChanged = model::reloadAfterMetadataChange,
|
onMatchChanged = model::reloadAfterMetadataChange,
|
||||||
onError = model::reportError,
|
onError = model::reportError,
|
||||||
onArrange = model::arrange,
|
onArrange = model::arrange,
|
||||||
|
onAddShare = {
|
||||||
|
destination = Destination.Shares
|
||||||
|
addingShare = true
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val titlePane = @Composable { selected: TitleRecord ->
|
val titlePane = @Composable { selected: TitleRecord ->
|
||||||
@@ -277,8 +282,10 @@ fun ProtonStreamApp(
|
|||||||
model::repairShare,
|
model::repairShare,
|
||||||
model::refreshShare,
|
model::refreshShare,
|
||||||
model::removeShare,
|
model::removeShare,
|
||||||
shareLink,
|
// The empty library's button opens the form as a
|
||||||
onShareLinkTaken,
|
// pasted link would, only with nothing in it.
|
||||||
|
shareLink ?: "".takeIf { addingShare },
|
||||||
|
{ addingShare = false; onShareLinkTaken() },
|
||||||
body,
|
body,
|
||||||
account = state.account,
|
account = state.account,
|
||||||
accountActions = AccountActions(
|
accountActions = AccountActions(
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
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.Key
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.FolderShared
|
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
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -330,6 +333,7 @@ private fun AddShareDialog(
|
|||||||
) {
|
) {
|
||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
||||||
|
var showLink by remember { mutableStateOf(false) }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
var adding by remember { mutableStateOf(false) }
|
var adding by remember { mutableStateOf(false) }
|
||||||
var refusal by remember { mutableStateOf<String?>(null) }
|
var refusal by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -340,17 +344,31 @@ private fun AddShareDialog(
|
|||||||
title = { Text("Add Proton Drive share") },
|
title = { Text("Add Proton Drive share") },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true)
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
url,
|
url,
|
||||||
{ url = it },
|
{ url = it },
|
||||||
label = { Text("Public share URL") },
|
label = { Text("Share link") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
// The URL fragment is a secret. Password input disables
|
// The URL fragment is a secret. Password input disables
|
||||||
// IME learning/suggestions while normal long-press paste
|
// IME learning/suggestions while normal long-press paste
|
||||||
// remains available.
|
// remains available; the eye shows what was pasted.
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = if (showLink) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
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(
|
OutlinedTextField(
|
||||||
password,
|
password,
|
||||||
@@ -381,7 +399,7 @@ private fun AddShareDialog(
|
|||||||
if (refusal == null) onDismiss()
|
if (refusal == null) onDismiss()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = name.isNotBlank() && url.isNotBlank() && !adding,
|
enabled = url.isNotBlank() && !adding,
|
||||||
) { Text(if (adding) "Adding…" else "Add") }
|
) { Text(if (adding) "Adding…" else "Add") }
|
||||||
},
|
},
|
||||||
dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { Text("Cancel") } },
|
dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { Text("Cancel") } },
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 59 KiB |
@@ -126,6 +126,8 @@ pub enum Action {
|
|||||||
Screenshot,
|
Screenshot,
|
||||||
/// A share link was pasted outside the form: open the form with it.
|
/// A share link was pasted outside the form: open the form with it.
|
||||||
PasteShare(String),
|
PasteShare(String),
|
||||||
|
/// Go to the add form, caret in the link field.
|
||||||
|
OpenShareForm,
|
||||||
AddShare {
|
AddShare {
|
||||||
name: String,
|
name: String,
|
||||||
url: String,
|
url: String,
|
||||||
@@ -524,6 +526,8 @@ pub struct ShareForm {
|
|||||||
pub sending: bool,
|
pub sending: bool,
|
||||||
/// Why the last one was refused.
|
/// Why the last one was refused.
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
/// Put the caret in the link field on the next frame the form is drawn.
|
||||||
|
pub focus: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
@@ -1512,7 +1516,11 @@ impl App {
|
|||||||
self.page = Page::Shares;
|
self.page = Page::Shares;
|
||||||
self.form.url = url;
|
self.form.url = url;
|
||||||
self.form.error = None;
|
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 {
|
Action::AddShare {
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -649,7 +649,7 @@ fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
|||||||
));
|
));
|
||||||
ui.add_space(theme::space::XL);
|
ui.add_space(theme::space::XL);
|
||||||
if ui::accent_button(ui, "Add a share").clicked() {
|
if ui::accent_button(ui, "Add a share").clicked() {
|
||||||
actions.push(Action::Goto(Page::Shares));
|
actions.push(Action::OpenShareForm);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ fn share_row(
|
|||||||
ui.add_space(theme::space::S);
|
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<Action>) {
|
fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||||
ui::section(ui, "Add a share");
|
ui::section(ui, "Add a share");
|
||||||
ui.label(ui::muted(
|
ui.label(ui::muted(
|
||||||
@@ -128,24 +137,28 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
|||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width().min(640.0));
|
ui.set_width(ui.available_width().min(640.0));
|
||||||
|
|
||||||
ui.label(ui::muted("Name"));
|
// Enter in any field of the form submits it, as the sign-in
|
||||||
ui.add(
|
// form's do.
|
||||||
egui::TextEdit::singleline(&mut form.name)
|
let mut entered = false;
|
||||||
.hint_text("Anime")
|
let mut submitted_from = |response: egui::Response| {
|
||||||
.desired_width(f32::INFINITY),
|
entered |= response.lost_focus() && ui_enter_pressed(&response.ctx);
|
||||||
);
|
};
|
||||||
ui.add_space(theme::space::M);
|
|
||||||
|
|
||||||
ui.label(ui::muted("Link"));
|
ui.label(ui::muted("Link"));
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let paste = 60.0;
|
let paste = 60.0;
|
||||||
let field = egui::Id::new("share-link");
|
let field = link_field_id();
|
||||||
ui.add(
|
if std::mem::take(&mut form.focus) {
|
||||||
egui::TextEdit::singleline(&mut form.url)
|
ui.memory_mut(|memory| memory.request_focus(field));
|
||||||
.id(field)
|
}
|
||||||
.password(true)
|
submitted_from(
|
||||||
.hint_text("https://drive.proton.me/urls/…#…")
|
ui.add(
|
||||||
.desired_width(ui.available_width() - paste),
|
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 —
|
// The link is masked, so pasting is the only sensible way in —
|
||||||
// and a button says that more plainly than the field does.
|
// 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.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");
|
ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password");
|
||||||
if form.has_password {
|
if form.has_password {
|
||||||
ui.add_space(theme::space::S);
|
ui.add_space(theme::space::S);
|
||||||
ui.add(
|
submitted_from(
|
||||||
egui::TextEdit::singleline(&mut form.password)
|
ui.add(
|
||||||
.password(true)
|
egui::TextEdit::singleline(&mut form.password)
|
||||||
.hint_text("Link password")
|
.password(true)
|
||||||
.desired_width(f32::INFINITY),
|
.hint_text("Link password")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
let ready = !form.name.trim().is_empty()
|
let ready = !form.url.trim().is_empty()
|
||||||
&& !form.url.trim().is_empty()
|
|
||||||
&& problem.is_none()
|
&& problem.is_none()
|
||||||
&& (!form.has_password || !form.password.is_empty())
|
&& (!form.has_password || !form.password.is_empty())
|
||||||
&& !form.sending;
|
&& !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.add_space(theme::space::S);
|
||||||
}
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui
|
let clicked = ui
|
||||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
|
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add share"))
|
||||||
.inner
|
.inner
|
||||||
.clicked()
|
.clicked();
|
||||||
{
|
if clicked || (ready && entered) {
|
||||||
actions.push(Action::AddShare {
|
actions.push(Action::AddShare {
|
||||||
name: form.name.trim().to_string(),
|
name: form.name.trim().to_string(),
|
||||||
url: form.url.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 {
|
if form.sending {
|
||||||
ui.add(egui::Spinner::new().size(14.0));
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
ui.label(ui::muted("adding…"));
|
ui.label(ui::muted("adding…"));
|
||||||
} else if form.name.trim().is_empty() || form.url.trim().is_empty() {
|
} else if form.url.trim().is_empty() {
|
||||||
ui.label(ui::muted("A name and a link are needed."));
|
ui.label(ui::muted("Paste a share link to add it."));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ impl ShareStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// [`Self::add`], but only once the link has been opened with the
|
/// [`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
|
/// 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
|
/// 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 custom_password = custom_password.filter(|password| !password.is_empty());
|
||||||
let client = open_link(url, custom_password).await?;
|
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))
|
Ok((share, client))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,7 +194,15 @@ struct RootView: View {
|
|||||||
/// tap rather than back and in again.
|
/// tap rather than back and in again.
|
||||||
@ViewBuilder private func libraryPage(width: CGFloat) -> some View {
|
@ViewBuilder private func libraryPage(width: CGFloat) -> some View {
|
||||||
let selected = selectedTitleKey.flatMap { key in model.titles.first { $0.key == key } }
|
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 {
|
if width >= twoPaneWidth {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
library.frame(width: width * 0.45)
|
library.frame(width: width * 0.45)
|
||||||
|
|||||||
@@ -79,17 +79,22 @@ struct IconButton: View {
|
|||||||
struct EmptyState: View {
|
struct EmptyState: View {
|
||||||
let title: String
|
let title: String
|
||||||
let body_: String
|
let body_: String
|
||||||
|
let action: (label: String, run: () -> Void)?
|
||||||
@Environment(\.scheme) private var scheme
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
init(_ title: String, _ body: String) {
|
init(_ title: String, _ body: String, action: (label: String, run: () -> Void)? = nil) {
|
||||||
self.title = title
|
self.title = title
|
||||||
body_ = body
|
body_ = body
|
||||||
|
self.action = action
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
Text(title).textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
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)
|
.multilineTextAlignment(.center)
|
||||||
.padding(24)
|
.padding(24)
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ struct LibraryView: View {
|
|||||||
@Environment(\.scheme) private var scheme
|
@Environment(\.scheme) private var scheme
|
||||||
let onResume: (TitleRecord, Int) -> Void
|
let onResume: (TitleRecord, Int) -> Void
|
||||||
let onTitle: (TitleRecord) -> Void
|
let onTitle: (TitleRecord) -> Void
|
||||||
|
var onAddShare: () -> Void = {}
|
||||||
|
|
||||||
// What a long press opened a menu for: a title, or a Continue watching card.
|
// What a long press opened a menu for: a title, or a Continue watching card.
|
||||||
@State private var menuFor: TitleRecord?
|
@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))”.")
|
EmptyState("Nothing matches", "No title in the library is called “\(model.query.trimmingCharacters(in: .whitespaces))”.")
|
||||||
} else if model.titles.isEmpty {
|
} else if model.titles.isEmpty {
|
||||||
ScrollView {
|
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)
|
.containerRelativeFrame(.vertical)
|
||||||
}
|
}
|
||||||
.refreshable { await model.crawlNow(nil) }
|
.refreshable { await model.crawlNow(nil) }
|
||||||
|
|||||||
@@ -211,15 +211,14 @@ private struct AddShareSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var ready: Bool {
|
private var ready: Bool {
|
||||||
!name.trimmingCharacters(in: .whitespaces).isEmpty && !url.trimmingCharacters(in: .whitespaces).isEmpty
|
!url.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
DialogSheet("Add Proton Drive share") {
|
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
|
// The URL fragment is a secret, so the field is a secure one: no
|
||||||
// keyboard learning, no suggestions, and paste still works.
|
// 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
|
// There is no share sheet into a sideloaded app, so a link copied
|
||||||
// elsewhere comes in from the clipboard.
|
// elsewhere comes in from the clipboard.
|
||||||
if url.isEmpty {
|
if url.isEmpty {
|
||||||
@@ -230,6 +229,8 @@ private struct AddShareSheet: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.quiet)
|
.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)
|
Field(label: "Custom password (optional)", text: $password, secret: true)
|
||||||
if adding {
|
if adding {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
|
|||||||
Reference in new issue
Block a user