mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(app,android,ios): display type, artwork fade-in, shimmer, lifting tiles and paged shelves
A first pass at making the three clients look like a streaming app rather than a file browser, with no new data behind it. - Type: a Hero rung (34) set in Inter Display Bold, the optical size Inter draws for large text, for the banner's name and a title page's name where no logo stands in. Desktop `Role::Hero` and `theme::display`; Android and iOS `displayLarge`, at a tighter 1.1 leading. The font has its private use mappings stripped like the other two cuts. - Artwork fades in over its skeleton on Android and iOS, as it already did on desktop cards; art already decoded draws at once. Desktop episode stills and logos fade in too. - Skeletons shimmer: a soft diagonal highlight instead of a pulse. - Desktop tiles lift towards the viewer under the pointer, capped so a lifted tile never reaches its neighbour, and keyboard focus gets a ring of its own. - Desktop shelves share one `ui::shelf`: edges fade into the page and, under the pointer, round buttons page the row along. - Genres are chips on Android and iOS; quality (4K, HDR, 10-bit, Dual audio) moves out of the facts line into small outlined marks on all three. - Desktop title page and hero buttons use a tonal style beside the accent one, and the player's top bar a dark glass style, instead of egui's stock buttons. Episode numbers leave monospace for the type ramp. - The phone players' top and bottom bars are gradients rather than flat bands, and a double-tap seek is felt as well as seen.
This commit is contained in:
1 parent
3ebfd1a57b
commit
255e463fc8
33 files changed
+886
-290
No files matched your search
Binary file not shown.
@@ -41,6 +41,10 @@ pub const CARD_GAP: f32 = 14.0;
|
||||
pub const POSTER_WIDTH: f32 = 168.0;
|
||||
/// Covers are 2:3.
|
||||
pub const POSTER_ASPECT: f32 = 3.0 / 2.0;
|
||||
/// How much a tile's picture grows under the pointer, as a fraction of its
|
||||
/// size. Half of it lands on each side, which has to fit in half of
|
||||
/// [`CARD_GAP`] at the widest card, or a lifted tile overlaps its neighbour.
|
||||
pub const TILE_LIFT: f32 = 0.04;
|
||||
|
||||
/// The type ramp, named once.
|
||||
///
|
||||
@@ -55,6 +59,11 @@ pub const POSTER_ASPECT: f32 = 3.0 / 2.0;
|
||||
/// the one monospace style in the app is egui's own, set in [`apply`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
/// The name in the library's banner and at the top of a title's page,
|
||||
/// where there is no logo to stand in for it. Set in Inter Display, the
|
||||
/// cut drawn for this size: tighter and heavier than the text face, which
|
||||
/// at forty points looks like body copy that was zoomed.
|
||||
Hero,
|
||||
/// The name of the thing you are looking at, once per page.
|
||||
Display,
|
||||
/// An empty state, or the heading of a page that has no display line.
|
||||
@@ -78,6 +87,7 @@ pub enum Role {
|
||||
impl Role {
|
||||
pub const fn size(self) -> f32 {
|
||||
match self {
|
||||
Self::Hero => 34.0,
|
||||
Self::Display => 26.0,
|
||||
Self::Title => 20.0,
|
||||
Self::Heading => 18.0,
|
||||
@@ -90,10 +100,11 @@ impl Role {
|
||||
}
|
||||
}
|
||||
|
||||
/// Headings are set in the semibold cut; everything below them in the
|
||||
/// regular one.
|
||||
/// The hero line is set in the display cut, headings in the semibold
|
||||
/// one, and everything below them in the regular one.
|
||||
pub fn family(self) -> egui::FontFamily {
|
||||
match self {
|
||||
Self::Hero => display(),
|
||||
Self::Display | Self::Title | Self::Heading | Self::Section => semibold(),
|
||||
_ => egui::FontFamily::Proportional,
|
||||
}
|
||||
@@ -159,6 +170,9 @@ pub mod motion {
|
||||
/// Artwork arriving over its placeholder. Slower, because it replaces
|
||||
/// something rather than adding to it, and a fast swap reads as a flicker.
|
||||
pub const ARRIVE: f32 = 0.24;
|
||||
/// One pass of the highlight across a loading skeleton. Slow, because it
|
||||
/// is something to wait in front of, not something happening.
|
||||
pub const SHIMMER: f32 = 1.4;
|
||||
|
||||
/// Fast out, slow in: the curve every hand-driven transition uses.
|
||||
pub fn ease_out(t: f32) -> f32 {
|
||||
@@ -170,7 +184,7 @@ pub mod motion {
|
||||
/// sets the same on every desktop. Inter, under the OFL — the licence is
|
||||
/// beside the files.
|
||||
///
|
||||
/// Both files have their private use area mappings removed, because the
|
||||
/// All three files have their private use area mappings removed, because the
|
||||
/// Phosphor icons live there. Inter maps 745 codepoints in that area to its
|
||||
/// own alternate glyphs, and as the first font in the family it won: the
|
||||
/// refresh arrow drew as an accented letter. The glyphs themselves are still
|
||||
@@ -179,6 +193,9 @@ pub mod motion {
|
||||
/// U+E000..=U+F8FF).
|
||||
const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf");
|
||||
const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.ttf");
|
||||
/// Inter Display Bold, the optical size for the hero line. Stripped like the
|
||||
/// other two.
|
||||
const INTER_DISPLAY: &[u8] = include_bytes!("../assets/fonts/InterDisplay-Bold.ttf");
|
||||
|
||||
/// The family the headings are set in. egui has no notion of weight, so the
|
||||
/// semibold cut is a family of its own.
|
||||
@@ -186,6 +203,11 @@ pub fn semibold() -> egui::FontFamily {
|
||||
egui::FontFamily::Name("semibold".into())
|
||||
}
|
||||
|
||||
/// The family [`Role::Hero`] is set in.
|
||||
pub fn display() -> egui::FontFamily {
|
||||
egui::FontFamily::Name("display".into())
|
||||
}
|
||||
|
||||
/// The family the subtitle preview is drawn in: the face the chosen subtitle
|
||||
/// font resolves to, ahead of the app's own fonts, which stand in until it has
|
||||
/// loaded and for any glyph it lacks.
|
||||
@@ -238,6 +260,10 @@ fn font_definitions(system: &SystemFonts) -> egui::FontDefinitions {
|
||||
"inter-semibold".into(),
|
||||
Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)),
|
||||
);
|
||||
fonts.font_data.insert(
|
||||
"inter-display".into(),
|
||||
Arc::new(egui::FontData::from_static(INTER_DISPLAY)),
|
||||
);
|
||||
fonts
|
||||
.families
|
||||
.entry(Proportional)
|
||||
@@ -258,10 +284,13 @@ fn font_definitions(system: &SystemFonts) -> egui::FontDefinitions {
|
||||
.filter(|name| *name != "inter")
|
||||
.cloned()
|
||||
.collect();
|
||||
let semibold_chain = std::iter::once("inter-semibold".to_owned())
|
||||
.chain(fallbacks)
|
||||
.collect();
|
||||
fonts.families.insert(semibold(), semibold_chain);
|
||||
let chain = |first: &str| {
|
||||
std::iter::once(first.to_owned())
|
||||
.chain(fallbacks.iter().cloned())
|
||||
.collect()
|
||||
};
|
||||
fonts.families.insert(semibold(), chain("inter-semibold"));
|
||||
fonts.families.insert(display(), chain("inter-display"));
|
||||
let mut subtitle_chain = fonts.families[&Proportional].clone();
|
||||
if let Some((_, face, _)) = &system.subtitle {
|
||||
let name = "system-subtitle".to_owned();
|
||||
@@ -868,6 +897,43 @@ fn shadow(strength: f32, offset: [i8; 2], blur: u8, alpha: u8) -> egui::epaint::
|
||||
}
|
||||
}
|
||||
|
||||
/// A soft highlight sweeping across a loading skeleton on a diagonal: what
|
||||
/// says the shape is waiting for something rather than empty.
|
||||
///
|
||||
/// The phase runs off the clock rather than per tile, so every skeleton on
|
||||
/// screen sweeps in step, as one surface. Asks for the next frame itself; a
|
||||
/// skeleton is only ever on screen for a moment.
|
||||
pub fn shimmer(painter: &egui::Painter, rect: Rect) {
|
||||
let time = painter.ctx().input(|input| input.time);
|
||||
let phase = (time / f64::from(motion::SHIMMER)).fract() as f32;
|
||||
let span = rect.width() * 0.6;
|
||||
let centre = rect.left() - span + phase * (rect.width() + 2.0 * span);
|
||||
let skew = rect.height() * 0.4;
|
||||
let highlight = if palette().light {
|
||||
Color32::from_black_alpha(10)
|
||||
} else {
|
||||
Color32::from_white_alpha(12)
|
||||
};
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for (x, color) in [
|
||||
(centre - span, Color32::TRANSPARENT),
|
||||
(centre, highlight),
|
||||
(centre + span, Color32::TRANSPARENT),
|
||||
] {
|
||||
mesh.colored_vertex(egui::pos2(x + skew, rect.top()), color);
|
||||
mesh.colored_vertex(egui::pos2(x - skew, rect.bottom()), color);
|
||||
}
|
||||
for quad in 0..2 {
|
||||
let i = quad * 2;
|
||||
mesh.add_triangle(i, i + 1, i + 2);
|
||||
mesh.add_triangle(i + 1, i + 3, i + 2);
|
||||
}
|
||||
painter
|
||||
.with_clip_rect(rect.intersect(painter.clip_rect()))
|
||||
.add(mesh);
|
||||
painter.ctx().request_repaint();
|
||||
}
|
||||
|
||||
/// The hover and press film on its own, for a control whose fill was painted by
|
||||
/// somebody else — the selected tab, which sits on the pill.
|
||||
pub fn veil_over(
|
||||
@@ -1169,7 +1235,11 @@ mod tests {
|
||||
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
|
||||
for (name, bytes) in [("regular", INTER), ("semibold", INTER_SEMIBOLD)] {
|
||||
for (name, bytes) in [
|
||||
("regular", INTER),
|
||||
("semibold", INTER_SEMIBOLD),
|
||||
("display", INTER_DISPLAY),
|
||||
] {
|
||||
let font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
|
||||
let shadowed: Vec<u32> = font
|
||||
.charmap()
|
||||
|
||||
@@ -277,7 +277,8 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
||||
}
|
||||
ui.add_space(theme::space::S);
|
||||
}
|
||||
if ui.button("More info").clicked() {
|
||||
let more = format!("{} More info", egui_phosphor::regular::INFO);
|
||||
if ui::tonal_button(ui, &more).clicked() {
|
||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||
}
|
||||
});
|
||||
@@ -303,15 +304,17 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
||||
ui.label(ui::muted(facts.join(" · ")));
|
||||
ui.add_space(theme::space::XXS);
|
||||
if ui::logo_heading(ui, art, title, 110.0).is_none() {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
theme::Role::Display
|
||||
.rich(art.name(title))
|
||||
.strong()
|
||||
.color(theme::text()),
|
||||
)
|
||||
.truncate(),
|
||||
// Two lines before it gives up: a long romanised name cut to
|
||||
// one at this size kept barely more than its first word.
|
||||
let mut job = egui::text::LayoutJob::simple(
|
||||
art.name(title).to_owned(),
|
||||
theme::Role::Hero.font(),
|
||||
theme::text(),
|
||||
ui.available_width().min(640.0),
|
||||
);
|
||||
job.wrap.max_rows = 2;
|
||||
job.wrap.overflow_character = Some('…');
|
||||
ui.label(job);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -382,36 +385,33 @@ fn shelf_row(
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
let (width, aspect) = tile_shape(tiles);
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt(("shelf", label))
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||
for title in titles {
|
||||
let picture = match tiles {
|
||||
Tiles::Posters => art.poster(title),
|
||||
Tiles::Stills => art.of(title),
|
||||
};
|
||||
let response = ui::card(
|
||||
ui,
|
||||
Card {
|
||||
art: picture,
|
||||
name: art.name(title),
|
||||
subtitle: subtitle(title),
|
||||
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||
badge: None,
|
||||
width,
|
||||
aspect,
|
||||
},
|
||||
);
|
||||
tile_menu(&response, title, false, actions);
|
||||
if response.clicked() {
|
||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||
}
|
||||
ui::shelf(ui, ("shelf", label), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||
for title in titles {
|
||||
let picture = match tiles {
|
||||
Tiles::Posters => art.poster(title),
|
||||
Tiles::Stills => art.of(title),
|
||||
};
|
||||
let response = ui::card(
|
||||
ui,
|
||||
Card {
|
||||
art: picture,
|
||||
name: art.name(title),
|
||||
subtitle: subtitle(title),
|
||||
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||
badge: None,
|
||||
width,
|
||||
aspect,
|
||||
},
|
||||
);
|
||||
tile_menu(&response, title, false, actions);
|
||||
if response.clicked() {
|
||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The minimum width and the picture's shape of a tile.
|
||||
@@ -429,48 +429,45 @@ fn continue_row(
|
||||
titles: &[&Title],
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt("continue")
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for title in titles {
|
||||
let Some(episode) = title.continue_with() else {
|
||||
continue;
|
||||
};
|
||||
let remaining = match episode.resume_at() {
|
||||
Some(at) => episode
|
||||
.watch
|
||||
.and_then(|watch| watch.duration_secs)
|
||||
.map(|duration| format!("{} left", ui::format_time(duration - at)))
|
||||
.unwrap_or_else(|| episode.label()),
|
||||
None => format!("Next: {}", episode.label()),
|
||||
};
|
||||
ui::shelf(ui, "continue", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for title in titles {
|
||||
let Some(episode) = title.continue_with() else {
|
||||
continue;
|
||||
};
|
||||
let remaining = match episode.resume_at() {
|
||||
Some(at) => episode
|
||||
.watch
|
||||
.and_then(|watch| watch.duration_secs)
|
||||
.map(|duration| format!("{} left", ui::format_time(duration - at)))
|
||||
.unwrap_or_else(|| episode.label()),
|
||||
None => format!("Next: {}", episode.label()),
|
||||
};
|
||||
|
||||
let picture = art.of(title);
|
||||
let response = ui::card(
|
||||
ui,
|
||||
Card {
|
||||
art: picture,
|
||||
name: art.name(title),
|
||||
subtitle: remaining,
|
||||
progress: episode.progress().map(|value| value as f32),
|
||||
badge: episode.numbering(),
|
||||
// A shelf that scrolls sideways has no right edge
|
||||
// to reach, so nothing to flex to. Always a
|
||||
// still: it is a frame from where they stopped.
|
||||
width: theme::CARD_WIDTH,
|
||||
aspect: theme::CARD_ASPECT,
|
||||
},
|
||||
);
|
||||
tile_menu(&response, title, true, actions);
|
||||
let picture = art.of(title);
|
||||
let response = ui::card(
|
||||
ui,
|
||||
Card {
|
||||
art: picture,
|
||||
name: art.name(title),
|
||||
subtitle: remaining,
|
||||
progress: episode.progress().map(|value| value as f32),
|
||||
badge: episode.numbering(),
|
||||
// A shelf that scrolls sideways has no right edge
|
||||
// to reach, so nothing to flex to. Always a
|
||||
// still: it is a frame from where they stopped.
|
||||
width: theme::CARD_WIDTH,
|
||||
aspect: theme::CARD_ASPECT,
|
||||
},
|
||||
);
|
||||
tile_menu(&response, title, true, actions);
|
||||
|
||||
if response.clicked() {
|
||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||
}
|
||||
if response.clicked() {
|
||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Every title, wrapped to the window.
|
||||
@@ -669,6 +666,7 @@ fn placeholder(ui: &mut egui::Ui) {
|
||||
egui::CornerRadius::same(theme::radius::MD),
|
||||
theme::card(),
|
||||
);
|
||||
theme::shimmer(ui.painter(), image);
|
||||
}
|
||||
});
|
||||
ui.add_space(theme::CARD_GAP);
|
||||
|
||||
@@ -235,13 +235,23 @@ pub fn logo_heading(
|
||||
if theme::palette().light {
|
||||
return None;
|
||||
}
|
||||
let texture = art.logo(title)?;
|
||||
let texture = art.logo(title);
|
||||
// From nothing rather than from the name it replaces: the name is gone the
|
||||
// frame the logo is ready, and a logo easing in reads as it arriving.
|
||||
let shown = ui.ctx().animate_bool_with_time(
|
||||
ui.id().with(("logo", &title.key)),
|
||||
texture.is_some(),
|
||||
theme::motion::ARRIVE,
|
||||
);
|
||||
let texture = texture?;
|
||||
let aspect = texture.aspect_ratio().max(0.1);
|
||||
let width = ui.available_width().min(420.0).min(max_height * aspect);
|
||||
let size = egui::vec2(width, width / aspect);
|
||||
Some(
|
||||
ui.add(egui::Image::new((texture.id(), size)))
|
||||
.on_hover_text(art.name(title)),
|
||||
ui.add(
|
||||
egui::Image::new((texture.id(), size)).tint(egui::Color32::WHITE.gamma_multiply(shown)),
|
||||
)
|
||||
.on_hover_text(art.name(title)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -342,6 +352,19 @@ pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0))
|
||||
}
|
||||
|
||||
/// The button beside the accent one: the same shape, in a film of the text
|
||||
/// colour, so "Start over" next to "Resume" reads as the alternative rather
|
||||
/// than as a second, unrelated kind of control.
|
||||
pub fn tonal_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
filled(ui, text, Fill::Tonal, Vec2::new(0.0, 0.0))
|
||||
}
|
||||
|
||||
/// A button laid over the picture: dark glass and white ink, whatever the
|
||||
/// theme, because what is under it is a video frame and not the page.
|
||||
pub fn glass_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
filled(ui, text, Fill::Glass, Vec2::new(0.0, 0.0))
|
||||
}
|
||||
|
||||
/// The search box in the top bar: a magnifier, the text, and a way to clear it.
|
||||
///
|
||||
/// Escape inside it clears it. `focus` puts the caret in it this frame — which
|
||||
@@ -548,6 +571,10 @@ enum Fill {
|
||||
Ink,
|
||||
/// Nothing at rest, the hover colour under the pointer.
|
||||
None,
|
||||
/// A film of the text colour over whatever is behind it.
|
||||
Tonal,
|
||||
/// Dark glass over the picture.
|
||||
Glass,
|
||||
}
|
||||
|
||||
/// A row of tabs with one accent pill that slides between them.
|
||||
@@ -668,6 +695,35 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
|
||||
}
|
||||
theme::muted().lerp_to_gamma(theme::text(), hover)
|
||||
}
|
||||
Fill::Tonal => {
|
||||
let film = if theme::palette().light {
|
||||
Color32::from_black_alpha(16)
|
||||
} else {
|
||||
Color32::from_white_alpha(22)
|
||||
};
|
||||
ui.painter().rect_filled(rect, radius, film);
|
||||
theme::veil_over(ui.painter(), rect, radius, lift);
|
||||
theme::text()
|
||||
}
|
||||
Fill::Glass => {
|
||||
ui.painter()
|
||||
.rect_filled(rect, radius, Color32::from_black_alpha(140));
|
||||
ui.painter().rect_stroke(
|
||||
rect,
|
||||
radius,
|
||||
Stroke::new(1.0, Color32::from_white_alpha(18)),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
theme::veil_over(ui.painter(), rect, radius, lift);
|
||||
Color32::WHITE
|
||||
}
|
||||
};
|
||||
// A disabled control is drawn at part strength: the ink is what says what
|
||||
// the button is, so that is what fades.
|
||||
let ink = if ui.is_enabled() {
|
||||
ink
|
||||
} else {
|
||||
ink.gamma_multiply(0.4)
|
||||
};
|
||||
|
||||
let position = rect.center() - galley.size() / 2.0;
|
||||
@@ -740,18 +796,27 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
return response;
|
||||
}
|
||||
|
||||
let image_rect = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
|
||||
let resting = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
|
||||
// Hover is a value, not a flag: the border, the lift and the shadow all
|
||||
// ride it, so they arrive together rather than each snapping on its own.
|
||||
// egui drives this from wall-clock time, so it is the same speed whatever
|
||||
// the frame rate — and it needs no state kept here.
|
||||
// Focus lights a tile the way the pointer does, so the keyboard can be
|
||||
// followed across the grid.
|
||||
// Focus lifts a tile the way the pointer does, and adds a ring of its own,
|
||||
// so the keyboard can be followed across the grid.
|
||||
let hover = ui.ctx().animate_bool_with_time(
|
||||
response.id.with("hover"),
|
||||
response.hovered() || response.has_focus(),
|
||||
theme::motion::HOVER,
|
||||
);
|
||||
let focus = ui.ctx().animate_bool_with_time(
|
||||
response.id.with("focus"),
|
||||
response.has_focus(),
|
||||
theme::motion::HOVER,
|
||||
);
|
||||
// The picture grows towards the viewer into the gap around it, which is
|
||||
// what says "this one" across a shelf of near-identical rectangles. The
|
||||
// name under it stays put, so the row's text never wobbles.
|
||||
let image_rect = resting.expand2(resting.size() * lift_per_side(width) * hover);
|
||||
// The picture arrives whenever its download finishes, several frames after
|
||||
// the tile first drew. Fading it in over the placeholder turns a wall of
|
||||
// letters popping into stills into one settle.
|
||||
@@ -867,9 +932,17 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
if focus > 0.0 {
|
||||
painter.rect_stroke(
|
||||
image_rect.expand(3.0),
|
||||
CornerRadius::same(theme::radius::MD + 3),
|
||||
Stroke::new(2.0, theme::text().gamma_multiply(focus)),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
}
|
||||
|
||||
let text_rect = Rect::from_min_size(
|
||||
image_rect.left_bottom() + Vec2::new(0.0, 6.0),
|
||||
resting.left_bottom() + Vec2::new(0.0, 6.0),
|
||||
Vec2::new(width, text_height - 6.0),
|
||||
);
|
||||
let painter = painter.with_clip_rect(text_rect);
|
||||
@@ -900,6 +973,146 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// Every sideways row in the app: the shelves on the home page and the
|
||||
/// franchise on a title's page.
|
||||
///
|
||||
/// What runs past either edge fades into the page rather than being cut off
|
||||
/// by it, so a row says there is more without a scroll bar saying it. Under
|
||||
/// the pointer, a round button at each edge with more beyond it pages the row
|
||||
/// by most of its width: a mouse has no sideways wheel, and the bar was the
|
||||
/// only other way across.
|
||||
///
|
||||
/// The row is padded by half a gap all round inside its scroll area, and the
|
||||
/// area reaches the same half gap past the column on either side, so a lifted
|
||||
/// tile is not clipped by the area and the tiles still line up with the
|
||||
/// headings above them.
|
||||
pub fn shelf<R>(
|
||||
ui: &mut egui::Ui,
|
||||
id_salt: impl std::hash::Hash + std::fmt::Debug,
|
||||
add: impl FnOnce(&mut egui::Ui) -> R,
|
||||
) -> R {
|
||||
let half = (theme::CARD_GAP / 2.0) as i8;
|
||||
let id = ui.id().with(("shelf", &id_salt));
|
||||
let page = id.with("page");
|
||||
let pending: Option<f32> = ui.ctx().data_mut(|data| data.remove_temp(page));
|
||||
let output = egui::Frame::new()
|
||||
.outer_margin(egui::Margin {
|
||||
left: -half,
|
||||
right: -half,
|
||||
top: -half,
|
||||
bottom: -half,
|
||||
})
|
||||
.show(ui, |ui| {
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt(id)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
if let Some(delta) = pending {
|
||||
ui.scroll_with_delta_animation(
|
||||
Vec2::new(delta, 0.0),
|
||||
egui::style::ScrollAnimation::duration(SHELF_PAGE_SECONDS),
|
||||
);
|
||||
}
|
||||
egui::Frame::new()
|
||||
.inner_margin(egui::Margin::same(half))
|
||||
.show(ui, add)
|
||||
.inner
|
||||
})
|
||||
})
|
||||
.inner;
|
||||
|
||||
let rect = output.inner_rect;
|
||||
let offset = output.state.offset.x;
|
||||
let overflow = (output.content_size.x - rect.width()).max(0.0);
|
||||
let ends = [
|
||||
(offset > 1.0, false, egui_phosphor::regular::CARET_LEFT, 1.0),
|
||||
(
|
||||
offset < overflow - 1.0,
|
||||
true,
|
||||
egui_phosphor::regular::CARET_RIGHT,
|
||||
-1.0,
|
||||
),
|
||||
];
|
||||
let pointer_in = ui.rect_contains_pointer(rect);
|
||||
let painter = ui.painter_at(rect);
|
||||
for (more, right, icon, direction) in ends {
|
||||
if !more {
|
||||
continue;
|
||||
}
|
||||
let strip = if right {
|
||||
Rect::from_min_max(egui::pos2(rect.right() - SHELF_FADE, rect.top()), rect.max)
|
||||
} else {
|
||||
Rect::from_min_max(
|
||||
rect.min,
|
||||
egui::pos2(rect.left() + SHELF_FADE, rect.bottom()),
|
||||
)
|
||||
};
|
||||
painter.add(theme::fade_shape(
|
||||
ui.ctx(),
|
||||
strip,
|
||||
theme::background(),
|
||||
theme::Direction::Horizontal,
|
||||
right,
|
||||
));
|
||||
|
||||
let button_id = id.with(right);
|
||||
let shown = ui
|
||||
.ctx()
|
||||
.animate_bool_with_time(button_id, pointer_in, theme::motion::HOVER);
|
||||
if shown == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let centre = egui::pos2(
|
||||
if right {
|
||||
rect.right() - SHELF_BUTTON / 2.0 - theme::space::M
|
||||
} else {
|
||||
rect.left() + SHELF_BUTTON / 2.0 + theme::space::M
|
||||
},
|
||||
rect.center().y,
|
||||
);
|
||||
let button = Rect::from_center_size(centre, Vec2::splat(SHELF_BUTTON));
|
||||
let response = ui
|
||||
.interact(button, button_id.with("button"), Sense::click())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
let response = response.named(if right { "Scroll right" } else { "Scroll left" });
|
||||
let lit = if response.hovered() { 1.0 } else { 0.0 };
|
||||
painter.circle_filled(
|
||||
centre,
|
||||
SHELF_BUTTON / 2.0,
|
||||
Color32::from_black_alpha((150.0 + 60.0 * lit) as u8).gamma_multiply(shown),
|
||||
);
|
||||
painter.text(
|
||||
centre,
|
||||
Align2::CENTER_CENTER,
|
||||
icon,
|
||||
egui::FontId::proportional(18.0),
|
||||
Color32::WHITE.gamma_multiply(shown),
|
||||
);
|
||||
if response.clicked() {
|
||||
let delta = direction * rect.width() * SHELF_PAGE;
|
||||
ui.ctx().data_mut(|data| data.insert_temp(page, delta));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
output.inner
|
||||
}
|
||||
|
||||
/// How wide the fade at a shelf's edge is.
|
||||
const SHELF_FADE: f32 = 56.0;
|
||||
/// The diameter of a shelf's paging buttons.
|
||||
const SHELF_BUTTON: f32 = 40.0;
|
||||
/// How much of a shelf's width one page moves: most of it, so the tile that
|
||||
/// was cut by the edge is whole on the next page.
|
||||
const SHELF_PAGE: f32 = 0.85;
|
||||
const SHELF_PAGE_SECONDS: f32 = 0.35;
|
||||
|
||||
/// How far a lifted tile grows past each edge, as a fraction of its size:
|
||||
/// [`theme::TILE_LIFT`], but never so far that it reaches into the gap's other
|
||||
/// half, which belongs to the neighbour. A wide window makes wide tiles.
|
||||
fn lift_per_side(width: f32) -> f32 {
|
||||
(theme::TILE_LIFT / 2.0).min((theme::CARD_GAP / 2.0 - 1.0) / width.max(1.0))
|
||||
}
|
||||
|
||||
/// The largest centred rectangle inside `target` with `image`'s aspect ratio.
|
||||
///
|
||||
/// The other half of [`cover_uv`]: where that one keeps the whole rectangle and
|
||||
@@ -1024,6 +1237,16 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_lifted_tile_never_reaches_its_neighbour_however_wide_the_window() {
|
||||
for available in [400.0, 1280.0, 1919.0, 2560.0, 3840.0] {
|
||||
for minimum in [theme::CARD_WIDTH, theme::POSTER_WIDTH] {
|
||||
let width = columns_of(available, minimum).width;
|
||||
assert!(width * lift_per_side(width) < theme::CARD_GAP / 2.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_time_reads_in_hours_and_minutes() {
|
||||
assert_eq!(format_runtime(24), "24 min");
|
||||
|
||||
@@ -707,13 +707,8 @@ fn chrome(
|
||||
.max_rect(top.shrink2(Vec2::new(18.0, 14.0)))
|
||||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||||
|ui| {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(theme::Role::Label.rich("Library"))
|
||||
.fill(Color32::from_black_alpha(160))
|
||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||
.min_size(Vec2::new(96.0, 32.0)),
|
||||
)
|
||||
let library = format!("{} Library", egui_phosphor::regular::CARET_LEFT);
|
||||
if ui::glass_button(ui, &library)
|
||||
.on_hover_text("Keep playing, and go back to the library (Esc)")
|
||||
.clicked()
|
||||
{
|
||||
@@ -736,69 +731,41 @@ fn chrome(
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let fullscreen = ui.input(|input| input.viewport().fullscreen.unwrap_or(false));
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(theme::Role::Subhead.rich(if fullscreen {
|
||||
egui_phosphor::regular::CORNERS_IN
|
||||
} else {
|
||||
egui_phosphor::regular::CORNERS_OUT
|
||||
}))
|
||||
.fill(Color32::from_black_alpha(160))
|
||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||
.min_size(Vec2::new(38.0, 32.0)),
|
||||
)
|
||||
.hint(if fullscreen {
|
||||
"Leave fullscreen (F)"
|
||||
if ui::glass_button(
|
||||
ui,
|
||||
if fullscreen {
|
||||
egui_phosphor::regular::CORNERS_IN
|
||||
} else {
|
||||
"Fullscreen (F)"
|
||||
})
|
||||
.clicked()
|
||||
egui_phosphor::regular::CORNERS_OUT
|
||||
},
|
||||
)
|
||||
.hint(if fullscreen {
|
||||
"Leave fullscreen (F)"
|
||||
} else {
|
||||
"Fullscreen (F)"
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::ToggleFullscreen);
|
||||
}
|
||||
if let Some(open) = drawer {
|
||||
ui.add_space(theme::space::S);
|
||||
let fill = if open {
|
||||
theme::accent()
|
||||
// In the accent while the drawer is out, so the button
|
||||
// that closes it is the one that says it is open.
|
||||
let icon = egui_phosphor::regular::LIST_BULLETS;
|
||||
let button = if open {
|
||||
ui::accent_button(ui, icon)
|
||||
} else {
|
||||
Color32::from_black_alpha(160)
|
||||
ui::glass_button(ui, icon)
|
||||
};
|
||||
let ink = if open {
|
||||
theme::on_accent()
|
||||
} else {
|
||||
Color32::WHITE
|
||||
};
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
theme::Role::Subhead
|
||||
.rich(egui_phosphor::regular::LIST_BULLETS)
|
||||
.color(ink),
|
||||
)
|
||||
.fill(fill)
|
||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||
.min_size(Vec2::new(38.0, 32.0)),
|
||||
)
|
||||
.hint("Episodes (E)")
|
||||
.clicked()
|
||||
{
|
||||
if button.hint("Episodes (E)").clicked() {
|
||||
actions.push(Action::ToggleEpisodes);
|
||||
}
|
||||
}
|
||||
// Esc leaves the page and keeps playing; this is the one-step
|
||||
// way to be done with the episode.
|
||||
ui.add_space(theme::space::S);
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
theme::Role::Subhead
|
||||
.rich(egui_phosphor::regular::STOP)
|
||||
.color(Color32::WHITE),
|
||||
)
|
||||
.fill(Color32::from_black_alpha(160))
|
||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||
.min_size(Vec2::new(38.0, 32.0)),
|
||||
)
|
||||
if ui::glass_button(ui, egui_phosphor::regular::STOP)
|
||||
.hint("Stop")
|
||||
.clicked()
|
||||
{
|
||||
@@ -1010,8 +977,9 @@ fn drawer_row(
|
||||
.unwrap_or_else(|| episode.detail().to_owned());
|
||||
if let Some(numbering) = episode.numbering() {
|
||||
ui.label(
|
||||
egui::RichText::new(numbering)
|
||||
.monospace()
|
||||
theme::Role::Caption
|
||||
.rich(numbering)
|
||||
.family(theme::semibold())
|
||||
.color(theme::muted()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,49 +121,46 @@ fn franchise_row(
|
||||
members: &[&Title],
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt(("franchise", ¤t.key))
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||
for member in members {
|
||||
let here = member.key == current.key;
|
||||
let picture = art.poster(member);
|
||||
let found = art
|
||||
.metadata
|
||||
.get(&member.key)
|
||||
.and_then(|record| record.metadata.as_ref());
|
||||
let what = found
|
||||
.and_then(|found| found.details.as_ref())
|
||||
.and_then(|details| details.format_label())
|
||||
.unwrap_or(match member.kind {
|
||||
TitleKind::Series => "Series",
|
||||
TitleKind::Film => "Film",
|
||||
});
|
||||
let year = found
|
||||
.and_then(|found| found.year)
|
||||
.or(member.year)
|
||||
.map(|year| format!(" · {year}"))
|
||||
.unwrap_or_default();
|
||||
let response = ui::card(
|
||||
ui,
|
||||
Card {
|
||||
art: picture,
|
||||
name: art.name(member),
|
||||
subtitle: format!("{what}{year}"),
|
||||
progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||
badge: here.then(|| "Here".to_owned()),
|
||||
width: 128.0,
|
||||
aspect: theme::POSTER_ASPECT,
|
||||
},
|
||||
);
|
||||
if !here && response.clicked() {
|
||||
actions.push(Action::Goto(Page::Title(member.key.clone())));
|
||||
}
|
||||
ui::shelf(ui, ("franchise", ¤t.key), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||
for member in members {
|
||||
let here = member.key == current.key;
|
||||
let picture = art.poster(member);
|
||||
let found = art
|
||||
.metadata
|
||||
.get(&member.key)
|
||||
.and_then(|record| record.metadata.as_ref());
|
||||
let what = found
|
||||
.and_then(|found| found.details.as_ref())
|
||||
.and_then(|details| details.format_label())
|
||||
.unwrap_or(match member.kind {
|
||||
TitleKind::Series => "Series",
|
||||
TitleKind::Film => "Film",
|
||||
});
|
||||
let year = found
|
||||
.and_then(|found| found.year)
|
||||
.or(member.year)
|
||||
.map(|year| format!(" · {year}"))
|
||||
.unwrap_or_default();
|
||||
let response = ui::card(
|
||||
ui,
|
||||
Card {
|
||||
art: picture,
|
||||
name: art.name(member),
|
||||
subtitle: format!("{what}{year}"),
|
||||
progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||
badge: here.then(|| "Here".to_owned()),
|
||||
width: 128.0,
|
||||
aspect: theme::POSTER_ASPECT,
|
||||
},
|
||||
);
|
||||
if !here && response.clicked() {
|
||||
actions.push(Action::Goto(Page::Title(member.key.clone())));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A title's poster, at the poster's own shape, with how far into it the
|
||||
@@ -218,7 +215,7 @@ fn season_watched(ui: &mut egui::Ui, title: &Title, season: &Season, actions: &m
|
||||
} else {
|
||||
"Mark season watched"
|
||||
};
|
||||
if ui.button(label).clicked() {
|
||||
if ui::tonal_button(ui, label).clicked() {
|
||||
actions.push(Action::MarkEpisodes {
|
||||
key: title.key.clone(),
|
||||
links: season
|
||||
@@ -324,7 +321,7 @@ fn header(
|
||||
// right of it rather than under it.
|
||||
ui.set_max_width(ui.available_width().min(820.0));
|
||||
if ui::logo_heading(ui, art, title, 120.0).is_none() {
|
||||
ui.label(theme::Role::Display.rich(&shown).strong());
|
||||
ui.label(theme::Role::Hero.rich(&shown).color(theme::text()));
|
||||
}
|
||||
// Whichever of the share's name and the provider's is not the one
|
||||
// shown. Worth showing rather than only replacing: a viewer should
|
||||
@@ -348,6 +345,19 @@ fn header(
|
||||
}
|
||||
ui.add_space(theme::space::XS);
|
||||
ui.label(ui::muted(meta_line(title, found.as_ref())));
|
||||
// What the files are, rather than what the title is: set apart
|
||||
// from the line above as small outlined marks, the way a box
|
||||
// prints them.
|
||||
let quality = title.quality().badges();
|
||||
if !quality.is_empty() {
|
||||
ui.add_space(theme::space::XS);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = theme::space::XS;
|
||||
for badge in quality {
|
||||
ui::widgets::spec_badge(ui, badge);
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(theme::space::L);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
@@ -374,8 +384,7 @@ fn header(
|
||||
}
|
||||
// Beside the button it is the alternative to.
|
||||
if resuming
|
||||
&& ui
|
||||
.button("Start over")
|
||||
&& ui::tonal_button(ui, "Start over")
|
||||
.on_hover_text("Play from the beginning")
|
||||
.clicked()
|
||||
{
|
||||
@@ -388,15 +397,13 @@ fn header(
|
||||
let title_keys: Vec<_> = title.episodes().map(key_of).collect();
|
||||
let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
|
||||
if all_offline {
|
||||
if ui
|
||||
.button("Delete show")
|
||||
if ui::tonal_button(ui, "Delete show")
|
||||
.on_hover_text("Delete the offline copies; keep the online source")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::DeleteOffline(title_keys, title.name.clone()));
|
||||
}
|
||||
} else if ui
|
||||
.button("Download show")
|
||||
} else if ui::tonal_button(ui, "Download show")
|
||||
.on_hover_text("Download every episode for disconnected playback")
|
||||
.clicked()
|
||||
{
|
||||
@@ -431,13 +438,15 @@ fn header(
|
||||
// wrongly. Offered whether or not anything matched: the two
|
||||
// cases it exists for are a title with no poster at all and a
|
||||
// title wearing someone else's.
|
||||
if ui
|
||||
.button(match &found {
|
||||
if ui::tonal_button(
|
||||
ui,
|
||||
match &found {
|
||||
Some(_) => "Change match",
|
||||
None => "Match…",
|
||||
})
|
||||
.on_hover_text("Search the metadata provider and pick the entry yourself")
|
||||
.clicked()
|
||||
},
|
||||
)
|
||||
.on_hover_text("Search the metadata provider and pick the entry yourself")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::OpenMatcher(title.key.clone()));
|
||||
}
|
||||
@@ -689,8 +698,6 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
||||
parts.push(format!("{total} on {}", found.provider.label()));
|
||||
}
|
||||
}
|
||||
// Last: what the files are, rather than what the title is.
|
||||
parts.extend(title.quality().badges().into_iter().map(str::to_owned));
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
@@ -753,7 +760,10 @@ fn episode_row(
|
||||
job.append(
|
||||
&format!("{numbering} "),
|
||||
0.0,
|
||||
egui::TextFormat::simple(egui::FontId::monospace(13.0), theme::muted()),
|
||||
egui::TextFormat::simple(
|
||||
egui::FontId::new(theme::Role::Label.size(), theme::semibold()),
|
||||
theme::muted(),
|
||||
),
|
||||
);
|
||||
}
|
||||
job.append(
|
||||
@@ -906,12 +916,20 @@ pub(crate) fn still(
|
||||
painter.rect_filled(rect, radius, theme::card_hover());
|
||||
// Asked for only once the row is on screen: a season of twenty-five is
|
||||
// twenty-five requests, and most of them are never scrolled to.
|
||||
if let Some(texture) = art.still(&title.key, episode) {
|
||||
let texture = art.still(&title.key, episode);
|
||||
// Faded in like a tile's art, so a season's stills settle rather than pop.
|
||||
let art_in = ui.ctx().animate_bool_with_time(
|
||||
response.id.with("art"),
|
||||
texture.is_some(),
|
||||
theme::motion::ARRIVE,
|
||||
);
|
||||
if let Some(texture) = texture {
|
||||
let tint = if watched {
|
||||
egui::Color32::from_gray(150)
|
||||
} else {
|
||||
egui::Color32::WHITE
|
||||
};
|
||||
}
|
||||
.gamma_multiply(art_in);
|
||||
painter.add(
|
||||
egui::epaint::RectShape::filled(rect, radius, tint)
|
||||
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
|
||||
|
||||
@@ -390,6 +390,25 @@ pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Respons
|
||||
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// A fact about the files rather than the title — 4K, HDR, Dual audio — as
|
||||
/// a small outlined mark: quieter than a [`chip`], which is something the
|
||||
/// title is filed under.
|
||||
pub fn spec_badge(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
egui::Frame::new()
|
||||
.stroke(egui::Stroke::new(1.0, theme::muted().gamma_multiply(0.7)))
|
||||
.corner_radius(CornerRadius::same(theme::radius::BADGE))
|
||||
.inner_margin(egui::Margin::symmetric(5, 1))
|
||||
.show(ui, |ui| {
|
||||
ui.label(
|
||||
theme::Role::Micro
|
||||
.rich(text)
|
||||
.family(theme::semibold())
|
||||
.color(theme::muted()),
|
||||
);
|
||||
})
|
||||
.response
|
||||
}
|
||||
|
||||
/// A small rounded label: a genre, a year, a fact.
|
||||
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
egui::Frame::new()
|
||||
|
||||
Reference in new issue
Block a user