Files
proton-stream/crates/pstr-app/src/ui/settings.rs
T
nvrl 85092fa893 refactor(app): spacing, radius and motion tokens
theme::space, theme::radius and theme::motion name the steps the UI is
drawn with. Literal gaps, corner radii and animation durations across ui/
now come from them, which folds a few near-misses onto the scale (10 and 14
point gaps onto 12, 10 point radii onto 12, 0.10 s hovers onto 0.12 s).
Page fades and toasts share one ease_out curve.
2026-10-02 23:10:39 +02:00

448 lines
17 KiB
Rust

//! Settings: how the app looks, how it plays, and what it asks the internet.
//!
//! Split out of the shares page, where these lived because that was the only
//! page with a form on it. Nobody looking for the theme looks under the page
//! that manages Proton Drive links, and a page that is half link management and
//! half preferences is two pages.
use pstr_core::appearance::{Accent, Appearance, Flavor};
use pstr_core::metadata::{MetadataConfig, ProviderId};
use crate::app::Action;
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
use crate::theme;
use crate::ui;
/// The settings that are a single value rather than a form.
///
/// Bundled for the same reason [`crate::ui::Art`] is: they arrive together,
/// they are read-only, and passing them one by one is what pushed this
/// function past the argument count anybody can read.
#[derive(Debug, Clone, Copy)]
pub struct Prefs {
pub autoplay: bool,
pub auto_skip: bool,
pub appearance: Appearance,
pub desktop: DesktopPrefs,
/// What the streaming cache holds, once the shares are open.
pub cache_used: Option<u64>,
}
/// The widest the settings column gets. Rows that run the width of a 2K window
/// put a switch a whole screen away from what it switches.
const COLUMN: f32 = 760.0;
pub fn show(
ui: &mut egui::Ui,
settings: &mut MetadataConfig,
prefs: Prefs,
api_key: &mut String,
actions: &mut Vec<Action>,
) {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
// Centred, so a wide window frames the column rather than leaving it
// against one edge with the rest of the page empty.
let width = ui.available_width().min(COLUMN);
let margin = ((ui.available_width() - width) / 2.0).max(0.0);
ui.horizontal(|ui| {
ui.add_space(margin);
ui.vertical(|ui| {
ui.set_width(width);
ui.add_space(theme::space::M);
appearance_form(ui, prefs.appearance, actions);
playback_form(ui, prefs, actions);
storage_form(ui, prefs, actions);
metadata_form(ui, settings, api_key, actions);
about(ui);
});
});
});
}
/// The theme: a flavour, an accent, and whether the accent is a gradient.
///
/// Every control here repaints the whole window the moment it is clicked —
/// there is no "apply" — because the preview *is* the app. A swatch row that
/// only changed a small square would be asking someone to imagine the result.
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
ui.add_space(theme::space::L);
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
ui.add_space(theme::space::M);
ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0);
ui.horizontal_wrapped(|ui| {
for flavor in Flavor::ALL {
if palette_card(ui, appearance, flavor)
.on_hover_text(flavor.description())
.clicked()
&& appearance.flavor != flavor
{
actions.push(Action::SetAppearance(Appearance {
flavor,
..appearance
}));
}
}
});
ui.add_space(theme::space::L);
ui.spacing_mut().item_spacing.y = 0.0;
ui::widgets::settings_row(ui, "Accent", None, |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
// Right to left, so reversed to read in the order they are listed.
for accent in Accent::ALL.into_iter().rev() {
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
actions.push(Action::SetAppearance(Appearance {
accent,
..appearance
}));
}
}
});
let mut gradients = appearance.gradients;
ui::widgets::settings_row(
ui,
"Gradient accent",
Some(
"Fade the accent into a second colour. Off draws it flat, which suits a panel that bands gradients into stripes.",
),
|ui| {
if ui::widgets::toggle(ui, &mut gradients, "").changed() {
actions.push(Action::SetAppearance(Appearance {
gradients,
..appearance
}));
}
},
);
});
}
/// One palette, as a miniature of the app wearing it: page, a card on it, a
/// line of text and the accent.
fn palette_card(ui: &mut egui::Ui, appearance: Appearance, flavor: Flavor) -> egui::Response {
let size = egui::vec2(132.0, 92.0);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if ui.is_rect_visible(rect) {
let palette = theme::Palette::resolve(Appearance {
flavor,
..appearance
});
let selected = appearance.flavor == flavor;
let hover =
ui.ctx()
.animate_bool_with_time(response.id, response.hovered(), theme::motion::HOVER);
let painter = ui.painter();
let radius = egui::CornerRadius::same(theme::radius::MD);
let preview = egui::Rect::from_min_size(rect.min, egui::vec2(size.x, 64.0));
painter.rect_filled(preview, radius, palette.background);
let bar = egui::Rect::from_min_size(preview.min, egui::vec2(size.x, 12.0));
painter.rect_filled(
bar,
egui::CornerRadius {
nw: 8,
ne: 8,
sw: 0,
se: 0,
},
palette.surface,
);
let tile = |x: f32| {
egui::Rect::from_min_size(preview.min + egui::vec2(x, 20.0), egui::vec2(34.0, 22.0))
};
for x in [8.0, 49.0, 90.0] {
painter.rect_filled(
tile(x),
egui::CornerRadius::same(theme::radius::BAR),
palette.card,
);
}
let pill =
egui::Rect::from_min_size(preview.min + egui::vec2(8.0, 3.0), egui::vec2(22.0, 6.0));
theme::swatch_fill(
painter,
pill,
egui::CornerRadius::same(theme::radius::BAR),
&palette,
);
painter.hline(
(preview.left() + 8.0)..=(preview.left() + 60.0),
preview.top() + 50.0,
egui::Stroke::new(2.0, palette.text),
);
painter.hline(
(preview.left() + 8.0)..=(preview.left() + 40.0),
preview.top() + 56.0,
egui::Stroke::new(2.0, palette.muted),
);
let ring = if selected {
egui::Stroke::new(2.0, theme::accent())
} else {
egui::Stroke::new(
1.0,
theme::card_hover().lerp_to_gamma(theme::muted(), hover),
)
};
painter.rect_stroke(preview, radius, ring, egui::StrokeKind::Outside);
painter.text(
egui::pos2(rect.left() + 2.0, preview.bottom() + 8.0),
egui::Align2::LEFT_TOP,
flavor.label(),
theme::Role::Label.font(),
if selected {
theme::text()
} else {
theme::muted()
},
);
}
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// One accent, drawn in the colours it would actually produce.
///
/// Resolved against the *selected* flavour rather than the active palette, so
/// the row previews what clicking it would give rather than what is on screen.
fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response {
let selected = appearance.accent == accent;
let size = egui::Vec2::splat(26.0);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if ui.is_rect_visible(rect) {
let palette = theme::Palette::resolve(Appearance {
accent,
..appearance
});
let radius = egui::CornerRadius::same(13);
theme::swatch_fill(ui.painter(), rect, radius, &palette);
if selected {
// A ring rather than a tick: the tick would have to be drawn in an
// ink that reads on eight different fills.
ui.painter().circle_stroke(
rect.center(),
rect.width() / 2.0 + 3.0,
egui::Stroke::new(2.0, theme::text()),
);
}
}
response
.on_hover_text(accent.label())
.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// The playback settings that are not per-file.
///
/// Volume, audio language, subtitle language and speed are all set from the
/// player itself, where a viewer can hear the result.
fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
ui::widgets::settings_group(
ui,
"Playback",
Some(
"Volume, audio, subtitles and speed are set from the player, and remembered from there.",
),
|ui| {
let mut autoplay = prefs.autoplay;
ui::widgets::settings_row(
ui,
"Play the next episode",
Some("When an episode ends, start the next one. Never after a file that failed."),
|ui| {
if ui::widgets::toggle(ui, &mut autoplay, "").changed() {
actions.push(Action::SetAutoplay(autoplay));
}
},
);
let mut auto_skip = prefs.auto_skip;
ui::widgets::settings_row(
ui,
"Skip openings and credits",
Some(
"Jump past chapters named as an opening, ending or preview, rather than offering a button. Chapter names are the release's guess, so this can skip a scene.",
),
|ui| {
if ui::widgets::toggle(ui, &mut auto_skip, "").changed() {
actions.push(Action::SetAutoSkip(auto_skip));
}
},
);
let mut desktop = prefs.desktop;
ui::widgets::settings_row(
ui,
"Hardware decoding",
Some(
"Decode on the graphics card. Turn off if video shows green frames or nothing at all; it costs CPU, not quality. From the next file.",
),
|ui| {
if ui::widgets::toggle(ui, &mut desktop.hardware_decoding, "").changed() {
actions.push(Action::SetDesktopPrefs(desktop));
}
},
);
},
);
}
/// The streaming cache: how big it may get, and a way to empty it.
fn storage_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
ui::widgets::settings_group(
ui,
"Storage",
Some(
"Video already fetched is kept on disk, so watching it again or seeking back costs nothing. Downloads are separate and never count towards this.",
),
|ui| {
let budget = ui::format_size(prefs.desktop.cache_bytes() as i64);
let used = match prefs.cache_used {
Some(used) => format!("{} of {budget} in use", ui::format_size(used as i64)),
None => format!("Up to {budget}"),
};
ui::widgets::settings_row(ui, "Streaming cache", Some(&used), |ui| {
let choices: Vec<(u32, String)> = CACHE_CHOICES
.iter()
.map(|&gib| (gib, format!("{gib} GiB")))
.collect();
let labels: Vec<(u32, &str)> = choices
.iter()
.map(|(gib, label)| (*gib, label.as_str()))
.collect();
if let Some(cache_gib) =
ui::widgets::segmented(ui, prefs.desktop.cache_gib, &labels)
{
actions.push(Action::SetDesktopPrefs(DesktopPrefs {
cache_gib,
..prefs.desktop
}));
}
});
ui::widgets::settings_row(
ui,
"Clear the streaming cache",
Some("Frees the space now. Whatever plays next is fetched again."),
|ui| {
if ui.button("Clear").clicked() {
actions.push(Action::ClearStreamCache);
}
},
);
},
);
}
/// What this is, and which version.
fn about(ui: &mut egui::Ui) {
ui::widgets::settings_group(ui, "About", None, |ui| {
ui::widgets::settings_row(
ui,
"proton-stream",
Some("Streams Proton Drive share links without an account or a download step."),
|ui| {
ui.label(ui::muted(concat!("version ", env!("CARGO_PKG_VERSION"))));
},
);
ui::widgets::settings_row(ui, "Keyboard shortcuts", None, |ui| {
ui.label(ui::muted("press ?"));
});
});
}
/// Enrichment: the switch, the provider, and the cost of turning it on.
///
/// The privacy note is not decoration and it is not in a tooltip. Turning this
/// on sends the titles in someone's library to a third party, which is a thing
/// they can only agree to if they are told — so it is stated where the switch
/// is, before the switch, in the same size as everything else.
fn metadata_form(
ui: &mut egui::Ui,
settings: &mut MetadataConfig,
api_key: &mut String,
actions: &mut Vec<Action>,
) {
ui::widgets::settings_group(ui, "Posters and descriptions", None, |ui| {
ui.add_space(theme::space::L);
ui.spacing_mut().item_spacing.y = 6.0;
ui.label(ui::muted(
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
without this every tile is a pair of initials. Turning it on sends the titles \
in your library — not your files, and nothing about what you have watched — to \
the provider you choose, over HTTPS, each time a new one appears.",
));
ui.add_space(theme::space::L);
let mut changed = false;
let mut enabled = settings.enabled;
if ui::widgets::toggle(ui, &mut enabled, "Look up posters and descriptions").changed() {
settings.enabled = enabled;
changed = true;
}
if settings.enabled {
ui.add_space(theme::space::L);
ui.label(ui::muted("Provider"));
for provider in ProviderId::ALL {
if ui
.radio(settings.provider == provider, provider.label())
.on_hover_text(provider.description())
.clicked()
&& settings.provider != provider
{
settings.provider = provider;
changed = true;
}
}
if settings.provider.needs_api_key() {
ui.add_space(theme::space::L);
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(api_key)
.password(true)
.hint_text("v3 API key")
.desired_width(300.0),
);
if ui.button("Save key").clicked() {
actions.push(Action::SetApiKey {
provider: settings.provider,
key: std::mem::take(api_key),
});
}
});
ui.label(ui::muted(
"Stored in your system keyring, never in a config file. Leave the box \
empty and save to forget it.",
));
}
ui.add_space(theme::space::L);
ui.horizontal(|ui| {
if ui::accent_button(ui, "Match the library").clicked() {
actions.push(Action::MatchTitles { force: false });
}
if ui
.button("Match everything again")
.on_hover_text("Ask about every title, including ones already matched")
.clicked()
{
actions.push(Action::MatchTitles { force: true });
}
});
} else {
ui.add_space(theme::space::S);
ui.label(ui::muted(
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
already stored.",
));
}
if changed {
actions.push(Action::SetMetadataConfig(settings.clone()));
}
ui.add_space(theme::space::L);
});
}