mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
AniList's per-season lookup searched for "<title> 2nd Season" and the scorer picked the base entry again, so Attack on Titan seasons 2-4 and Jigokuraku season 2 were captioned with season one's names. Episode names came from streamingEpisodes, which is partial and numbered however a streaming site counted. Bleach S17 and Mushishi S02/S03 had none. AniList now walks SEQUEL relations from the match and takes each entry's episodes from ani.zip, which carries TVDB's season, number and absolute number. An episode row keeps all three plus its place in the AniList entry. EpisodeGuide::get matches whichever numbering a file uses and never answers from another season. Episodes and fanart are stored as one enrichment. The episode list is replaced even when empty, and the enrichment is recorded as asked, so a film is not re-asked every run. Storing a different match drops the old entry's episode rows. AniList's 4.75:1 bannerImage is no longer stored as the 16:9 backdrop; ani.zip's 1920x1080 fanart is used instead. Schema v9 adds the columns and deletes the AniList episode rows and banners. Matches are kept. Desktop, Android and the CLI now share service::plan and MetadataService::work. The settings text names ani.zip. Audit and roadmap in docs/METADATA.md; bug entry B61.
450 lines
17 KiB
Rust
450 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. With AniList, \
|
|
the ids it matched — not the titles — then go to ani.zip for episode names and \
|
|
wide artwork.",
|
|
));
|
|
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);
|
|
});
|
|
}
|