feat(app): polish pass - toasts, shortcuts, redesigned pages, cheaper frames

- toasts replace the status line; keyboard shortcuts with a ? overlay
- library: filters, sort, row virtualisation, cached search, skeleton
  tiles until the catalog loads, gradient placeholder art
- title page: season pills, episode rows with stills and one download
  glyph, portrait poster, clamped overview, resume time in the button
- player: fading chrome, smooth scrims, subtitles lifted over the
  controls, drag-following seek knob, OSD, speed menu, auto-skip
- settings as grouped cards with toggles and palette previews
- shares: confirm before removing, inline link validation, form kept
  until the add succeeds; downloads scroll and show rate and ETA
- downloads pipeline four blocks; the next episode is warmed near the end
- a film behind another page repaints at 4 Hz, not its frame rate

Fixes B55-B60.
This commit is contained in:
nvrl committed 2026-10-02 19:27:28 +02:00
1 parent c5adcddb1e
commit 4027ab5fb3
20 files changed
+3389 -763

No files matched your search

+6 -1
View File
@@ -92,7 +92,12 @@ impl Harness {
.context("eframe was built without a glow context")?;
// SAFETY: `CreationContext` is handed to us on the UI thread with the
// context current, which is the same guarantee `App::ui` gives.
let video = unsafe { VideoSurface::new(Arc::clone(&player), gl, cc.egui_ctx.clone()) }?;
let video = unsafe {
VideoSurface::new(Arc::clone(&player), gl, {
let ctx = cc.egui_ctx.clone();
move || ctx.request_repaint()
})
}?;
Ok(Self {
player,
File diff suppressed because it is too large. Load diff
+195 -12
View File
@@ -80,9 +80,17 @@ pub struct DownloadItem {
pub state: DownloadState,
pub downloaded: u64,
pub total: u64,
/// Bytes a second, smoothed. Only meaningful while it is running.
pub rate: f64,
}
impl DownloadItem {
/// Seconds until done at the current rate, while that is knowable.
pub fn eta(&self) -> Option<f64> {
(self.state == DownloadState::Running && self.rate > 0.0 && self.total > self.downloaded)
.then(|| (self.total - self.downloaded) as f64 / self.rate)
}
pub fn percent(&self) -> f32 {
if self.total == 0 {
0.0
@@ -153,6 +161,10 @@ const THUMBNAIL_MAX_EDGE: u32 = 640;
pub enum Event {
/// The share list changed on disk.
Shares(Vec<Share>),
/// The share the form asked for is stored; its crawl is starting.
ShareAdded(String),
/// The share the form asked for was refused, with why. Nothing was crawled.
ShareRejected(String),
/// Shares opened; playback is possible from here.
Connected {
/// Shares that did not open, with why.
@@ -169,6 +181,8 @@ pub enum Event {
files: usize,
seconds: f64,
},
/// A crawl is under way: which share, and how many nodes so far.
CrawlProgress { share: String, found: usize },
/// Every requested crawl is done.
CrawlFinished,
/// A decoded Proton thumbnail, keyed as [`thumbnail_key`].
@@ -287,6 +301,8 @@ pub struct Engine {
track_prefs: Arc<Mutex<HashMap<String, TitleTrackPrefs>>>,
downloads: Arc<Mutex<Downloads>>,
download_permits: Arc<tokio::sync::Semaphore>,
/// Whether the player page is on screen. See [`Engine::wake`].
picture_shown: Arc<AtomicBool>,
events: Sender<Event>,
ctx: egui::Context,
}
@@ -297,6 +313,10 @@ const THUMBNAIL_CONCURRENCY: usize = 6;
/// How many provider lookups may be in flight at once. See [`Engine::lookups`].
const LOOKUP_CONCURRENCY: usize = 2;
const DOWNLOAD_CONCURRENCY: usize = 3;
/// How many blocks of one download are fetched at once. Four is what the
/// stream layer itself allows a single read, for the same reason: enough to
/// hide a round trip, few enough that three downloads do not swamp a player.
const DOWNLOAD_BLOCKS_IN_FLIGHT: usize = 4;
/// Enrichment as currently configured.
struct Enrichment {
@@ -378,6 +398,7 @@ impl Engine {
track_prefs: Arc::new(Mutex::new(track_prefs)),
downloads: Arc::new(Mutex::new(Downloads::default())),
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
picture_shown: Arc::new(AtomicBool::new(false)),
events,
ctx,
};
@@ -441,8 +462,43 @@ impl Engine {
/// Send an event and wake the UI. A closed channel means the window is
/// gone, which is not an error worth propagating anywhere.
pub(crate) fn emit(&self, event: Event) {
let ticking = matches!(
event,
Event::Player {
event: pstr_player::PlayerEvent::Position(_),
..
}
);
if self.events.send(event).is_ok() {
if ticking {
self.wake();
} else {
self.ctx.request_repaint();
}
}
}
/// Say whether the player page is the one on screen.
pub fn set_picture_shown(&self, shown: bool) {
self.picture_shown.store(shown, Ordering::Relaxed);
}
/// Ask for a frame because playback moved on.
///
/// mpv reports the position and offers a new picture once per video frame.
/// On the player page that is exactly the rate to draw at. Anywhere else
/// the film is a line of text and a bar in the transport, and redrawing a
/// whole library page at the film's frame rate to move them is most of the
/// app's CPU while something plays in the background. Four times a second
/// keeps the clock honest.
pub fn wake(&self) {
/// How often the transport bar moves while the picture is hidden.
const BACKGROUND_TICK: std::time::Duration = std::time::Duration::from_millis(250);
if self.picture_shown.load(Ordering::Relaxed) {
self.ctx.request_repaint();
} else {
self.ctx.request_repaint_after(BACKGROUND_TICK);
}
}
@@ -468,9 +524,9 @@ impl Engine {
self.runtime.spawn(async move {
let share = match engine.store.add(&name, &url, password.as_deref()) {
Ok(share) => share,
Err(error) => return engine.fail("add that share", error),
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
};
engine.emit(Event::Status(format!("added {}", share.name)));
engine.emit(Event::ShareAdded(share.name.clone()));
engine.load_shares();
engine.connect_and_crawl(Some(share.id)).await;
});
@@ -677,6 +733,7 @@ impl Engine {
state: DownloadState::Completed,
downloaded: expected,
total: expected,
rate: 0.0,
});
}
} else {
@@ -726,7 +783,30 @@ impl Engine {
for share_id in targets {
let started = std::time::Instant::now();
let nodes = match library.crawl(&share_id).await {
let name = self
.store
.list()
.ok()
.and_then(|shares| shares.into_iter().find(|share| share.id == share_id))
.map_or_else(|| share_id.clone(), |share| share.name);
self.emit(Event::CrawlProgress {
share: name.clone(),
found: 0,
});
// At most a few times a second: a wide level comes back as a burst
// of listings, and a frame per listing is a frame per folder.
let last = parking_lot::Mutex::new(std::time::Instant::now());
let report = |found: usize| {
let mut last = last.lock();
if last.elapsed() >= std::time::Duration::from_millis(250) {
*last = std::time::Instant::now();
self.emit(Event::CrawlProgress {
share: name.clone(),
found,
});
}
};
let nodes = match library.crawl_reporting(&share_id, report).await {
Ok(nodes) => nodes,
Err(error) => {
self.fail(&format!("crawl {share_id}"), error);
@@ -903,15 +983,53 @@ impl Engine {
Ok(stream) if stream.size() == 0 => {
engine.emit(Event::Error(format!("{} has no content", target.name)));
}
Ok(stream) => engine.emit(Event::PlaybackReady {
target: Box::new(target),
stream,
}),
Ok(stream) => {
// Before mpv is even built: building it takes the UI thread
// a moment, and the network can spend that moment on the
// two reads every file opens with.
stream.warm_edges();
engine.emit(Event::PlaybackReady {
target: Box::new(target),
stream,
})
}
Err(error) => engine.fail(&format!("open {}", target.name), error),
}
});
}
/// Fetch the blocks a file opens with into the cache, without playing it.
///
/// For the episode after the one playing, so that autoplay — or a click on
/// "Play now" — starts from the cache rather than from two round trips. A
/// local copy needs none of this, and a failure here costs nothing but the
/// head start, so both are quietly skipped.
pub fn warm(&self, target: &PlaybackTarget) {
let engine = self.clone();
let share_id = target.share_id.clone();
let link_id = target.link_id.clone();
let uid = node_uid(&target.volume_id, &target.link_id);
self.runtime.spawn(async move {
let offline = engine
.catalog
.lock()
.offline_file(&share_id, &link_id)
.ok()
.flatten()
.is_some();
if offline {
return;
}
let Some(source) = engine.opened().map(|(_, source)| source) else {
return;
};
match source.open(&share_id, &uid).await {
Ok(stream) => stream.warm_edges(),
Err(error) => tracing::debug!(%error, "warming the next episode failed"),
}
});
}
/// Queue complete plaintext copies. Jobs for the same episode are
/// de-duplicated; a failed or cancelled row is a resumable retry.
pub fn make_offline(&self, targets: Vec<PlaybackTarget>) {
@@ -946,6 +1064,7 @@ impl Engine {
state: DownloadState::Queued,
downloaded: previous.as_ref().map_or(0, |item| item.downloaded),
total: previous.as_ref().map_or(0, |item| item.total),
rate: 0.0,
},
);
}
@@ -1191,6 +1310,7 @@ impl Engine {
state: DownloadState::Cancelled,
downloaded,
total,
rate: 0.0,
});
}
let mut downloads = self.downloads.lock();
@@ -1229,7 +1349,7 @@ impl Engine {
anyhow::bail!("share is not connected");
};
let stream = source
.open(
.open_for_copy(
&target.share_id,
&node_uid(&target.volume_id, &target.link_id),
)
@@ -1298,14 +1418,27 @@ impl Engine {
output.seek(std::io::SeekFrom::Start(offset)).await?;
self.update_download(&key, |item| item.downloaded = offset);
use futures::TryStreamExt as _;
let mut fetches = None;
let mut last_snapshot = std::time::Instant::now();
let mut snapshot_bytes = offset;
while block_index < block_sizes.len() {
// Dropped before a pause rather than held across it: a fetch left
// half-done for as long as the viewer stays paused comes back as a
// timed-out request, and a timed-out request fails the download.
if matches!(*command.borrow(), DownloadCommand::Pause) {
fetches = None;
}
if !wait_until_runnable(&mut command).await {
output.sync_all().await?;
return Ok(DownloadEnd::Cancelled);
}
let size = block_sizes[block_index];
let bytes = stream.read_range(offset, size).await?;
let pending =
fetches.get_or_insert_with(|| fetch_in_order(&stream, &block_sizes, block_index));
let Some(bytes) = pending.try_next().await? else {
anyhow::bail!("block {block_index} never arrived");
};
if bytes.len() as u64 != size {
anyhow::bail!(
"short block {block_index}: received {}, expected {size}",
@@ -1322,11 +1455,21 @@ impl Engine {
if last_snapshot.elapsed() >= std::time::Duration::from_millis(100)
|| block_index == block_sizes.len()
{
let seconds = last_snapshot.elapsed().as_secs_f64();
let instant = (offset - snapshot_bytes) as f64 / seconds.max(f64::EPSILON);
self.update_download(&key, |item| {
item.state = DownloadState::Running;
item.downloaded = offset;
// Smoothed, or the time left jumps about with every block
// that took a little longer than the last.
item.rate = if item.rate > 0.0 {
0.8 * item.rate + 0.2 * instant
} else {
instant
};
});
last_snapshot = std::time::Instant::now();
snapshot_bytes = offset;
}
}
output.sync_all().await?;
@@ -1891,6 +2034,35 @@ fn resume_position(existing: u64, block_sizes: &[u64]) -> (usize, u64) {
(block_sizes.len(), offset)
}
/// A file's blocks from `first` on, fetched several at a time and yielded in
/// file order — which is what the resume marker's "the length is the
/// boundary" rule needs. One at a time, a download runs at one block per round
/// trip whatever the link can carry.
fn fetch_in_order(
stream: &VideoStream,
block_sizes: &[u64],
first: usize,
) -> futures::stream::BoxStream<'static, pstr_stream::Result<Vec<u8>>> {
use futures::StreamExt as _;
let start: u64 = block_sizes[..first].iter().sum();
let ranges: Vec<(u64, u64)> = block_sizes[first..]
.iter()
.scan(start, |offset, size| {
let range = (*offset, *size);
*offset += size;
Some(range)
})
.collect();
let stream = stream.clone();
futures::stream::iter(ranges)
.map(move |(offset, size)| {
let stream = stream.clone();
async move { stream.read_range(offset, size).await }
})
.buffered(DOWNLOAD_BLOCKS_IN_FLIGHT)
.boxed()
}
async fn wait_until_runnable(command: &mut tokio::sync::watch::Receiver<DownloadCommand>) -> bool {
loop {
let current = *command.borrow_and_update();
@@ -2008,11 +2180,15 @@ impl ImageCache {
///
/// `None` while a fetch is in flight, and forever for a picture that turned
/// out not to exist — the negative answer is remembered deliberately.
pub fn texture(&mut self, key: String, fetch: impl FnOnce()) -> Option<egui::TextureHandle> {
if let Some(texture) = self.textures.get(&key) {
///
/// Borrows the key: this is asked once per tile per frame, and a hit — the
/// usual answer — should not cost an allocation.
pub fn texture(&mut self, key: &str, fetch: impl FnOnce()) -> Option<egui::TextureHandle> {
if let Some(texture) = self.textures.get(key) {
return Some(texture.clone());
}
if !self.missing.contains(&key) && self.requested.insert(key) {
if !self.missing.contains(key) && !self.requested.contains(key) {
self.requested.insert(key.to_owned());
fetch();
}
None
@@ -2027,6 +2203,13 @@ impl ImageCache {
self.missing.insert(key);
}
/// Forget one picture, so the next ask fetches it again.
pub fn forget(&mut self, key: &str) {
self.textures.remove(key);
self.requested.remove(key);
self.missing.remove(key);
}
/// Forget everything. Used when the catalog is replaced, so a recrawl that
/// changed link ids does not leave stale pictures behind, and when the
/// metadata provider changes, so the old provider's artwork goes with it.
+42 -5
View File
@@ -134,6 +134,11 @@ pub enum Command {
/// every file it loads afterwards, so picking Japanese audio for one
/// episode picks it for the next one too.
SelectTrack(TrackKind, Option<i64>),
/// Move subtitles, as a percentage of the window's height. See
/// [`Playback::lift_subtitles`].
SetSubtitlePosition(f64),
/// 1.0 is normal speed.
SetSpeed(f64),
Stop,
}
@@ -162,6 +167,11 @@ pub struct Playback {
/// 0–100, as mpv has it.
pub volume: f64,
pub muted: bool,
/// Playback speed, 1.0 being normal.
pub speed: f64,
/// Where subtitles were last put, so they are only moved when that changes
/// rather than once a frame.
subtitle_position: f64,
/// What the file contains. Empty until mpv has demuxed it.
pub tracks: Vec<Track>,
/// The file's chapters, in order. Empty for a file muxed without them.
@@ -196,10 +206,9 @@ impl Playback {
target: PlaybackTarget,
stream: VideoStream,
gl: Option<&Arc<glow::Context>>,
ctx: &egui::Context,
) -> Result<Self, String> {
let prefs = engine.playback_prefs();
let (player, video) = build_player(engine, &target, &prefs, gl, ctx)?;
let (player, video) = build_player(engine, &target, &prefs, gl)?;
let (commands, orders) = channel::<Command>();
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
@@ -234,6 +243,8 @@ impl Playback {
paused: false,
volume: prefs.volume,
muted: prefs.muted,
subtitle_position: 100.0,
speed: prefs.speed,
tracks: Vec::new(),
chapters: Vec::new(),
roles: Vec::new(),
@@ -246,6 +257,24 @@ impl Playback {
})
}
/// Keep subtitles clear of `covered` points at the bottom of a picture
/// `height` points tall — the controls, while they are up.
///
/// Without it the line being read sits under the seek bar for as long as
/// the mouse is moving, which is exactly when someone is looking at the
/// screen.
pub fn lift_subtitles(&mut self, covered: f32, height: f32) {
let position = if covered > 0.0 && height > 0.0 {
(100.0 * (1.0 - f64::from(covered / height))).round()
} else {
100.0
};
if position != self.subtitle_position {
self.subtitle_position = position;
self.send(Command::SetSubtitlePosition(position));
}
}
/// Whether the picture is drawn in this window rather than mpv's own.
pub fn is_embedded(&self) -> bool {
self.video.is_some()
@@ -365,7 +394,6 @@ fn build_player(
target: &PlaybackTarget,
prefs: &PlaybackPrefs,
gl: Option<&Arc<glow::Context>>,
ctx: &egui::Context,
) -> Result<(Arc<Player>, Option<VideoSurface>), String> {
let title = format!("proton-stream — {}", target.name);
@@ -386,8 +414,12 @@ fn build_player(
// SAFETY: called from `App::ui`, where eframe has made this
// context current, and the surface is dropped from the same
// place — see `video.rs`.
match unsafe { VideoSurface::new(Arc::clone(&player), Arc::clone(gl), ctx.clone()) }
{
match unsafe {
VideoSurface::new(Arc::clone(&player), Arc::clone(gl), {
let engine = engine.clone();
move || engine.wake()
})
} {
Ok(video) => return Ok((player, Some(video))),
Err(error) => {
tracing::warn!("embed the video: {error}; falling back to a window");
@@ -428,6 +460,9 @@ fn from_prefs(
.and_then(|p| p.subtitle_language.clone())
.or_else(|| prefs.subtitle_language.clone()),
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
// Carried from file to file, like the volume: somebody who watches at
// 1.25 watches the next episode at 1.25 too.
options: vec![("speed".into(), format!("{:.2}", prefs.speed))],
..PlayerConfig::default()
}
}
@@ -569,6 +604,8 @@ fn apply_command(
Command::SeekTo(seconds) => player.seek_to(seconds),
Command::SetVolume(volume) => player.set_volume(volume),
Command::SetMuted(muted) => player.set_muted(muted),
Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent),
Command::SetSpeed(speed) => player.set_speed(speed),
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
Command::Stop => {
let _ = player.quit();
+30
View File
@@ -358,6 +358,36 @@ fn fill(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, palett
painter.add(textured(rect, corner_radius, &texture));
}
/// A rounded rectangle running from one colour to another, at `opacity`.
///
/// For colours that are not the palette's — a title's own, on a tile with no
/// picture. Drawn flat when the viewer has ramps off, like every other ramp.
pub fn ramp_fill(
painter: &egui::Painter,
rect: Rect,
corner_radius: CornerRadius,
(from, to): (Color32, Color32),
direction: Direction,
opacity: f32,
) {
let tint = Color32::WHITE.gamma_multiply(opacity);
if !ramps_on() {
painter.rect_filled(
rect,
corner_radius,
mix(from, to, 0.5).gamma_multiply(opacity),
);
return;
}
let texture = ramp(painter.ctx(), from, to, direction);
painter.add(
egui::epaint::RectShape::filled(rect, corner_radius, tint).with_texture(
texture.id(),
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
),
);
}
/// A ramp from `color` to nothing, for laying artwork into the page.
///
/// The other ramps here run between two opaque colours; this one runs down its
+196 -134
View File
@@ -5,62 +5,112 @@ use crate::engine::{DownloadItem, DownloadState};
use crate::{theme, ui};
pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Action>) {
ui.heading("Downloads");
ui.label(ui::muted(
"Pause and Cancel keep complete downloaded blocks. Delete partial discards them.",
));
ui.add_space(12.0);
if downloads.is_empty() {
ui.label("No offline downloads.");
ui::empty_state(
ui,
"No downloads",
"Download an episode, a season or a whole show from its page, and it plays \
without a connection.",
);
return;
}
let mut start = 0;
while start < downloads.len() {
let title_key = &downloads[start].target.title_key;
let title = &downloads[start].target.title_name;
let end = downloads[start..]
.iter()
.position(|item| item.target.title_key != *title_key)
.map_or(downloads.len(), |offset| start + offset);
let group = &downloads[start..end];
let downloaded: u64 = group.iter().map(|item| item.downloaded).sum();
let total: u64 = group.iter().map(|item| item.total).sum();
let completed = group
.iter()
.filter(|item| item.state == DownloadState::Completed)
.count();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.set_max_width(960.0);
summary(ui, downloads);
ui.add_space(12.0);
ui.label(theme::Role::Section.rich(title).strong());
let totals_known = group.iter().all(|item| item.total > 0);
ui.horizontal(|ui| {
if totals_known {
ui.add(
egui::ProgressBar::new(fraction(downloaded, total))
.desired_width(260.0)
.show_percentage(),
);
} else {
ui.add(egui::Spinner::new().size(16.0));
ui.label(ui::muted("Calculating size…"));
let mut start = 0;
while start < downloads.len() {
let title_key = &downloads[start].target.title_key;
let end = downloads[start..]
.iter()
.position(|item| item.target.title_key != *title_key)
.map_or(downloads.len(), |offset| start + offset);
group(ui, &downloads[start..end], actions);
ui.add_space(14.0);
start = end;
}
ui.label(ui::muted(format!(
"{} / {} · {completed}/{} files",
bytes(downloaded),
if !totals_known {
"calculating…".to_owned()
} else {
bytes(total)
},
group.len()
)));
});
for item in group {
row(ui, item, actions);
}
/// The line under the heading: how much, how fast, how long.
fn summary(ui: &mut egui::Ui, downloads: &[DownloadItem]) {
ui::section(ui, "Downloads");
let running: Vec<_> = downloads
.iter()
.filter(|item| item.state == DownloadState::Running)
.collect();
let rate: f64 = running.iter().map(|item| item.rate).sum();
let left: u64 = downloads
.iter()
.filter(|item| {
matches!(
item.state,
DownloadState::Queued | DownloadState::Running | DownloadState::Paused
)
})
.map(|item| item.total.saturating_sub(item.downloaded))
.sum();
let mut parts = vec![format!(
"{} available offline",
ui::library::plural(
downloads
.iter()
.filter(|item| item.state == DownloadState::Completed)
.count(),
"file"
)
)];
if !running.is_empty() {
parts.push(format!("{}/s", size(rate as u64)));
}
if left > 0 {
parts.push(format!("{} to go", size(left)));
if rate > 0.0 {
parts.push(format!("about {}", ui::format_time(left as f64 / rate)));
}
ui.add_space(12.0);
start = end;
}
ui.label(ui::muted(parts.join(" · ")));
ui.label(ui::muted(
"Pausing or cancelling keeps every block already downloaded.",
));
}
/// One title's downloads, under a bar for all of them together.
fn group(ui: &mut egui::Ui, group: &[DownloadItem], actions: &mut Vec<Action>) {
let title = &group[0].target.title_name;
let downloaded: u64 = group.iter().map(|item| item.downloaded).sum();
let total: u64 = group.iter().map(|item| item.total).sum();
let completed = group
.iter()
.filter(|item| item.state == DownloadState::Completed)
.count();
let totals_known = group.iter().all(|item| item.total > 0);
ui.horizontal(|ui| {
ui.label(theme::Role::Subhead.rich(title).strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(ui::muted(if totals_known {
format!(
"{completed} of {} · {} of {}",
group.len(),
size(downloaded),
size(total)
)
} else {
format!("{completed} of {} · working out the size…", group.len())
}));
});
});
if totals_known && group.len() > 1 {
ui::progress_bar(ui, fraction(downloaded, total), ui.available_width());
}
ui.add_space(6.0);
for item in group {
row(ui, item, actions);
}
}
@@ -68,7 +118,7 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin::symmetric(10, 8))
.inner_margin(egui::Margin::symmetric(12, 8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
@@ -78,85 +128,109 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
item.target.subtitle.as_str()
};
ui.add_sized(
[180.0, 18.0],
egui::Label::new(egui::RichText::new(caption).strong()).truncate(),
[72.0, 18.0],
egui::Label::new(egui::RichText::new(caption).monospace()).truncate(),
)
.on_hover_text(&item.target.name);
let status = match &item.state {
DownloadState::Queued => "Queued".to_owned(),
DownloadState::Running => "Downloading".to_owned(),
DownloadState::Paused => "Paused".to_owned(),
DownloadState::Completed => "Available offline".to_owned(),
DownloadState::Cancelled => "Cancelled · partial kept".to_owned(),
DownloadState::Failed(error) => format!("Failed: {error}"),
};
let (status, colour) = status(item);
ui.add_sized(
[240.0, 18.0],
egui::Label::new(ui::muted(status)).truncate(),
[220.0, 18.0],
egui::Label::new(theme::Role::Caption.rich(status).color(colour)).truncate(),
);
if item.total > 0 {
ui.add(
egui::ProgressBar::new(item.percent())
.desired_width(140.0)
.show_percentage(),
);
ui.label(ui::muted(format!(
"{} / {}",
bytes(item.downloaded),
bytes(item.total)
)));
}
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| match item.state {
DownloadState::Queued | DownloadState::Running => {
if ui
.button("Cancel")
.on_hover_text("Stop and keep partial")
.clicked()
{
actions.push(Action::CancelDownload(item.key.clone()));
}
if ui.button("Pause").clicked() {
actions.push(Action::PauseDownload(item.key.clone()));
}
}
DownloadState::Paused => {
if ui
.button("Cancel")
.on_hover_text("Stop and keep partial")
.clicked()
{
actions.push(Action::CancelDownload(item.key.clone()));
}
if ui.button("Resume").clicked() {
actions.push(Action::ResumeDownload(item.key.clone()));
}
}
DownloadState::Cancelled | DownloadState::Failed(_) => {
if ui.button("Delete partial…").clicked() {
actions.push(Action::RemoveDownload(item.key.clone(), true));
}
if ui.button("Resume / Retry").clicked() {
actions.push(Action::ResumeDownload(item.key.clone()));
}
}
DownloadState::Completed => {
if ui
.button("Delete")
.on_hover_text("Delete the offline copy; keep the online source")
.clicked()
{
actions.push(Action::RemoveDownload(item.key.clone(), false));
}
}
},
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
buttons(ui, item, actions);
if item.total > 0 && item.state != DownloadState::Completed {
ui.add_space(8.0);
ui.label(ui::muted(format!(
"{} / {}",
size(item.downloaded),
size(item.total)
)));
let width = (ui.available_width() - 8.0).clamp(40.0, 320.0);
ui::progress_bar(ui, item.percent(), width);
}
});
});
});
ui.add_space(4.0);
}
/// What a row says about itself, and in what colour.
fn status(item: &DownloadItem) -> (String, egui::Color32) {
match &item.state {
DownloadState::Queued => ("Waiting".to_owned(), theme::muted()),
DownloadState::Running => {
let mut text = format!("{}/s", size(item.rate as u64));
if let Some(eta) = item.eta() {
text.push_str(&format!(" · {} left", ui::format_time(eta)));
}
(text, theme::text())
}
DownloadState::Paused => ("Paused".to_owned(), theme::muted()),
DownloadState::Completed => ("Available offline".to_owned(), theme::muted()),
DownloadState::Cancelled => ("Stopped · progress kept".to_owned(), theme::muted()),
DownloadState::Failed(error) => (format!("Failed: {error}"), theme::danger()),
}
}
fn buttons(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
let key = || item.key.clone();
match item.state {
DownloadState::Queued | DownloadState::Running => {
if ui
.button("Cancel")
.on_hover_text("Stop, and keep what has downloaded")
.clicked()
{
actions.push(Action::CancelDownload(key()));
}
if ui.button("Pause").clicked() {
actions.push(Action::PauseDownload(key()));
}
}
DownloadState::Paused => {
if ui
.button("Cancel")
.on_hover_text("Stop, and keep what has downloaded")
.clicked()
{
actions.push(Action::CancelDownload(key()));
}
if ui.button("Resume").clicked() {
actions.push(Action::ResumeDownload(key()));
}
}
DownloadState::Cancelled | DownloadState::Failed(_) => {
if ui
.button("Delete")
.on_hover_text("Discard what has downloaded so far")
.clicked()
{
actions.push(Action::RemoveDownload(key(), true));
}
if ui
.button(if matches!(item.state, DownloadState::Failed(_)) {
"Retry"
} else {
"Resume"
})
.clicked()
{
actions.push(Action::ResumeDownload(key()));
}
}
DownloadState::Completed => {
if ui
.button("Delete")
.on_hover_text("Delete the offline copy; keep the online source")
.clicked()
{
actions.push(Action::RemoveDownload(key(), false));
}
}
}
}
fn fraction(downloaded: u64, total: u64) -> f32 {
@@ -167,18 +241,6 @@ fn fraction(downloaded: u64, total: u64) -> f32 {
}
}
fn bytes(value: u64) -> String {
const KIB: f64 = 1024.0;
const MIB: f64 = 1024.0 * KIB;
const GIB: f64 = 1024.0 * MIB;
let value = value as f64;
if value >= GIB {
format!("{:.1} GiB", value / GIB)
} else if value >= MIB {
format!("{:.1} MiB", value / MIB)
} else if value >= KIB {
format!("{:.1} KiB", value / KIB)
} else {
format!("{value:.0} B")
}
fn size(bytes: u64) -> String {
ui::format_size(i64::try_from(bytes).unwrap_or(i64::MAX))
}
+137 -23
View File
@@ -2,30 +2,49 @@
use pstr_core::library::{Library, Title, TitleKind};
use crate::app::{Action, Page};
use crate::app::{Action, Filter, LibraryView, Page, Sort};
use crate::theme;
use crate::ui::Art;
use crate::ui::{self, Card};
pub fn show(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
search: &str,
actions: &mut Vec<Action>,
) {
/// What the page lists, and what it was asked for.
pub struct Shelves<'a> {
pub library: &'a Library,
/// The search and the shelf, already worked out — see [`LibraryView`].
pub view: &'a LibraryView,
/// Whether the catalog has been read yet.
pub loaded: bool,
pub search: &'a str,
}
pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec<Action>) {
let Shelves {
library,
view,
loaded,
search,
} = shelves;
if !loaded {
return placeholder(ui);
}
if library.is_empty() {
return empty_state(ui, actions);
}
let matches = library.search(search);
let pick = |indices: &[usize]| -> Vec<&Title> {
indices
.iter()
.filter_map(|&index| library.titles.get(index))
.collect()
};
let matches = pick(&view.matches);
let searching = !search.trim().is_empty();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if !searching {
let resumable = library.continue_watching();
let resumable = pick(&view.resumable);
if !resumable.is_empty() {
ui::section(ui, "Continue watching");
continue_row(ui, art, &resumable, actions);
@@ -33,22 +52,32 @@ pub fn show(
}
}
ui::section(
ui,
&if searching {
format!(
"{} matching {:?}",
plural(matches.len(), "title"),
search.trim()
)
} else {
plural(matches.len(), "title")
},
);
ui.horizontal(|ui| {
ui::section(
ui,
&if searching {
format!(
"{} matching {:?}",
plural(matches.len(), "title"),
search.trim()
)
} else {
plural(matches.len(), "title")
},
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
shelf_controls(ui, view, actions);
});
});
ui.add_space(6.0);
if matches.is_empty() {
ui.add_space(8.0);
ui.label(ui::muted("Nothing here by that name."));
ui.label(ui::muted(if searching {
"Nothing here by that name."
} else {
"Nothing in the library fits that filter."
}));
return;
}
@@ -56,6 +85,48 @@ pub fn show(
});
}
/// The filter pills and the order, at the right of the grid's heading.
fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Action>) {
// Right to left: the order first, so it ends up last.
let sort = egui::ComboBox::from_id_salt("library-sort")
.selected_text(match view.sort {
Sort::Name => "A – Z",
Sort::Recent => "Recently watched",
Sort::Year => "Newest",
})
.width(150.0)
.show_ui(ui, |ui| {
let mut picked = None;
for (sort, label) in [
(Sort::Name, "A – Z"),
(Sort::Recent, "Recently watched"),
(Sort::Year, "Newest"),
] {
if ui.selectable_label(view.sort == sort, label).clicked() {
picked = Some(sort);
}
}
picked
});
if let Some(Some(sort)) = sort.inner {
actions.push(Action::SetShelf(view.filter, sort));
}
ui.add_space(10.0);
if let Some(filter) = ui::widgets::segmented(
ui,
view.filter,
&[
(Filter::All, "All"),
(Filter::Series, "Series"),
(Filter::Films, "Films"),
(Filter::Watching, "Watching"),
(Filter::Unwatched, "Not started"),
],
) {
actions.push(Action::SetShelf(filter, view.sort));
}
}
/// The top shelf: one card per part-watched title, scrolling sideways.
fn continue_row(
ui: &mut egui::Ui,
@@ -111,7 +182,22 @@ fn continue_row(
/// Every title, wrapped to the window.
fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) {
let grid = ui::columns(ui.available_width());
let row_height = ui::card_height(grid.width);
for row in titles.chunks(grid.columns) {
// A row scrolled out of view is only its height. Laying it out anyway
// costs a subtitle and an artwork lookup per card per frame, and while
// a film plays under the transport bar a frame is every frame.
let slot = egui::Rect::from_min_size(
ui.cursor().min,
egui::vec2(ui.available_width(), row_height),
);
if !ui.is_rect_visible(slot) {
// The spacing `horizontal` would have added after itself, too, or
// the page grows and shrinks under the scroll bar as rows come and
// go.
ui.add_space(row_height + ui.spacing().item_spacing.y + theme::CARD_GAP);
continue;
}
ui.horizontal(|ui| {
// The gap the width was divided around, so what is drawn matches
// what was measured. egui's default item spacing is narrower, and
@@ -168,6 +254,34 @@ fn subtitle(title: &Title) -> String {
}
}
/// The page before the catalog has been read: the shape of a grid, with
/// nothing in it yet.
///
/// Not the empty state. The catalog is on disk and is read in a fraction of a
/// second, and telling every launch "Nothing in the library yet" for that
/// fraction is a flash of something false.
fn placeholder(ui: &mut egui::Ui) {
let grid = ui::columns(ui.available_width());
let height = ui::card_height(grid.width);
ui::section(ui, "Library");
for _ in 0..2 {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for _ in 0..grid.columns {
let (rect, _) =
ui.allocate_exact_size(egui::vec2(grid.width, height), egui::Sense::hover());
let image = egui::Rect::from_min_size(
rect.min,
egui::vec2(grid.width, (grid.width * theme::CARD_ASPECT).round()),
);
ui.painter()
.rect_filled(image, egui::CornerRadius::same(8), theme::card());
}
});
ui.add_space(theme::CARD_GAP);
}
}
fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
ui.vertical_centered(|ui| {
ui.add_space(120.0);
+1 -2
View File
@@ -258,9 +258,8 @@ fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option<egui::TextureHandl
.or_else(|| found.backdrop_url.clone())?;
let key = format!("option:{}:{}", found.provider.as_str(), found.remote_id);
let engine = art.engine;
let requested = key.clone();
art.posters
.texture(key, || engine.request_poster(requested, url))
.texture(&key, || engine.request_poster(key.clone(), url))
}
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out
+276 -34
View File
@@ -9,8 +9,11 @@ pub mod matcher;
pub mod player;
pub mod settings;
pub mod shares;
pub mod shortcuts;
pub mod title;
pub mod toast;
pub mod transport;
pub mod widgets;
use std::collections::HashMap;
@@ -48,6 +51,28 @@ impl Art<'_> {
.get(episode.node.parsed.season, number)
}
/// A still for one episode: the provider's, else Proton's own thumbnail of
/// the file. Only asked for by rows that are on screen.
pub fn still(&mut self, title_key: &str, episode: &Episode) -> Option<egui::TextureHandle> {
let url = self
.episode(title_key, episode)
.and_then(|found| found.still_url.clone());
if let Some(url) = url {
let key = format!("still:{}", url);
let engine = self.engine;
if let Some(texture) = self
.posters
.texture(&key, || engine.request_poster(key.clone(), url))
{
return Some(texture);
}
}
let node = &episode.node;
let engine = self.engine;
self.thumbs
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))
}
/// The picture for a title, and how to fit it.
///
/// Provider artwork first, then Proton's still, then nothing. That order is
@@ -62,12 +87,9 @@ impl Art<'_> {
.and_then(|metadata| metadata.tile_art())
{
let engine = self.engine;
let key = title.key.clone();
let url = url.to_string();
if let Some(texture) = self
.posters
.texture(title.key.clone(), || engine.request_poster(key, url))
{
if let Some(texture) = self.posters.texture(&title.key, || {
engine.request_poster(title.key.clone(), url.to_string())
}) {
return Some((texture, shape));
}
}
@@ -76,7 +98,7 @@ impl Art<'_> {
let engine = self.engine;
let texture = self
.thumbs
.texture(thumbnail_key(node), || engine.request_thumbnail(node))?;
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))?;
Some((texture, ArtShape::Landscape))
}
}
@@ -95,6 +117,12 @@ pub fn format_time(seconds: f64) -> String {
}
}
/// `1.25×`, or `1×`.
pub fn format_speed(speed: f64) -> String {
let text = format!("{speed:.2}");
format!("{}×", text.trim_end_matches('0').trim_end_matches('.'))
}
/// `1.4 GiB`, for what a file costs to watch.
pub fn format_size(bytes: i64) -> String {
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
@@ -133,6 +161,184 @@ pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
filled(ui, text, Fill::Accent, 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
/// is what `Ctrl+F` and `/` ask for from anywhere in the library.
pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
let id = egui::Id::new("library-search");
let width = 240.0;
let height = ui.spacing().interact_size.y;
let (outer, _) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover());
let has_focus = ui.memory(|memory| memory.has_focus(id));
let lit = ui
.ctx()
.animate_bool_with_time(id.with("lit"), has_focus, 0.12);
let radius = CornerRadius::same((height / 2.0) as u8);
ui.painter().rect_filled(outer, radius, theme::card());
ui.painter().rect_stroke(
outer,
radius,
Stroke::new(1.0, theme::card_hover().lerp_to_gamma(theme::accent(), lit)),
egui::StrokeKind::Inside,
);
// A magnifier: a ring and a handle, so no font has to have the glyph.
let ink = theme::muted().lerp_to_gamma(theme::text(), lit);
let lens = egui::pos2(outer.left() + 16.0, outer.center().y - 1.0);
ui.painter().circle_stroke(lens, 5.0, Stroke::new(1.5, ink));
ui.painter().line_segment(
[lens + Vec2::new(3.6, 3.6), lens + Vec2::new(7.0, 7.0)],
Stroke::new(1.5, ink),
);
let clear_width = if search.is_empty() { 0.0 } else { 26.0 };
let field_rect = Rect::from_min_max(
egui::pos2(outer.left() + 28.0, outer.top()),
egui::pos2(outer.right() - 8.0 - clear_width, outer.bottom()),
);
let response = ui.put(
field_rect,
egui::TextEdit::singleline(search)
.id(id)
.frame(egui::Frame::NONE)
.hint_text(if has_focus {
"Titles and filenames"
} else {
"Search Ctrl+F"
})
.vertical_align(egui::Align::Center)
.desired_width(field_rect.width()),
);
if focus {
response.request_focus();
}
if response.has_focus() && ui.input(|input| input.key_pressed(egui::Key::Escape)) {
search.clear();
response.surrender_focus();
}
if !search.is_empty() {
let clear = Rect::from_center_size(
egui::pos2(outer.right() - 18.0, outer.center().y),
Vec2::splat(18.0),
);
let response = ui.interact(clear, id.with("clear"), Sense::click());
let ink = if response.hovered() {
theme::text()
} else {
theme::muted()
};
let c = clear.center();
let d = 3.5;
ui.painter().line_segment(
[c + Vec2::new(-d, -d), c + Vec2::new(d, d)],
Stroke::new(1.5, ink),
);
ui.painter().line_segment(
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
Stroke::new(1.5, ink),
);
if response
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("Clear (Esc)")
.clicked()
{
search.clear();
}
}
}
/// A thin bar in the accent, for how far something has got.
///
/// egui's own progress bar is one flat colour with the percentage written over
/// it; this is the same bar the tiles and the seek bar use, so a download reads
/// as part of the same app.
pub fn progress_bar(ui: &mut egui::Ui, fraction: f32, width: f32) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(Vec2::new(width, 6.0), Sense::hover());
if ui.is_rect_visible(rect) {
let radius = CornerRadius::same(3);
ui.painter().rect_filled(rect, radius, theme::card_hover());
let mut done = rect;
done.set_width(rect.width() * fraction.clamp(0.0, 1.0));
if done.width() > 0.5 {
theme::accent_fill(ui.painter(), done, radius, 0.0);
}
}
response
}
/// A centred message for a page with nothing on it: what is missing, and what
/// to do about it.
pub fn empty_state(ui: &mut egui::Ui, heading: &str, body: &str) {
ui.vertical_centered(|ui| {
ui.add_space(96.0);
ui.label(theme::Role::Title.rich(heading).strong());
ui.add_space(6.0);
ui.label(muted(body));
});
}
/// The danger colour as a button: for the one choice in a dialog that cannot be
/// taken back.
pub fn danger_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
ui.add(
egui::Button::new(egui::RichText::new(text).color(theme::on_accent()))
.fill(theme::danger()),
)
}
/// What a viewer answered a [`confirm`] dialog with.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Answer {
/// Still open.
Pending,
Confirmed,
/// Cancelled — by the button, Escape, or a click beside the dialog.
Declined,
}
/// A question with two answers, over everything else.
///
/// For the actions that destroy something the viewer cannot get back from
/// inside the app — stored secrets, downloaded bytes. Everything else acts on
/// the click.
pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer {
let mut answer = Answer::Pending;
let modal = egui::Modal::new(egui::Id::new(("confirm", id)))
.frame(
egui::Frame::new()
.fill(theme::surface())
.inner_margin(egui::Margin::same(18))
.corner_radius(CornerRadius::same(10)),
)
.show(ctx, |ui| {
ui.set_width(420.0);
ui.label(
theme::Role::Heading
.rich(heading)
.strong()
.color(theme::text()),
);
ui.add_space(6.0);
ui.label(theme::Role::Body.rich(body).color(theme::muted()));
ui.add_space(16.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if danger_button(ui, verb).clicked() {
answer = Answer::Confirmed;
}
if ui.button("Cancel").clicked() {
answer = Answer::Declined;
}
});
});
if answer == Answer::Pending && modal.should_close() {
answer = Answer::Declined;
}
answer
}
/// What [`filled`] paints behind its label.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Fill {
@@ -281,6 +487,17 @@ pub struct Card<'a> {
pub width: f32,
}
/// Two lines of name plus one of subtitle. Fixed, because the tile is allocated
/// before the name is laid out — and because a grid whose rows are each a
/// different height by title length reads as broken.
const CARD_TEXT_HEIGHT: f32 = 54.0;
/// How tall [`card`] draws a tile of this width, so a grid can step over rows
/// it does not draw.
pub fn card_height(width: f32) -> f32 {
(width * theme::CARD_ASPECT).round() + CARD_TEXT_HEIGHT
}
/// Draw one tile and report whether it was clicked.
///
/// A landscape picture is cropped to fill: a grid of differently-shaped black
@@ -292,10 +509,7 @@ pub struct Card<'a> {
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let width = card.width;
let image_height = (width * theme::CARD_ASPECT).round();
// Two lines of name plus one of subtitle. Fixed, because the tile is
// allocated before the name is laid out — and because a grid whose rows are
// each a different height by title length reads as broken.
let text_height = 54.0;
let text_height = CARD_TEXT_HEIGHT;
let radius = CornerRadius::same(8);
let (rect, response) =
@@ -328,13 +542,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
// Under the picture, not instead of it: it is what shows through while the
// art fades in, and it is the whole tile when there is no art to come.
if art_in < 1.0 {
painter.text(
image_rect.center(),
Align2::CENTER_CENTER,
initials(card.name),
egui::FontId::proportional(28.0),
theme::muted().gamma_multiply(1.0 - art_in),
);
placeholder(painter, image_rect, card.name, 1.0 - art_in);
}
// White because a textured rect *multiplies* its fill by the texture; the
@@ -470,7 +678,7 @@ fn contain_rect(image: Vec2, target: Rect) -> Rect {
}
/// UV rectangle that crops `image` to fill `target` without distorting it.
fn cover_uv(image: Vec2, target: Vec2) -> Rect {
pub fn cover_uv(image: Vec2, target: Vec2) -> Rect {
if image.x <= 0.0 || image.y <= 0.0 || target.x <= 0.0 || target.y <= 0.0 {
return Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
}
@@ -485,14 +693,55 @@ fn cover_uv(image: Vec2, target: Vec2) -> Rect {
Rect::from_center_size(egui::pos2(0.5, 0.5), Vec2::new(width, height))
}
/// Up to two letters, for a tile with no picture.
fn initials(name: &str) -> String {
name.split_whitespace()
.filter_map(|word| word.chars().next())
.filter(|character| character.is_alphanumeric())
.take(2)
.flat_map(char::to_uppercase)
.collect()
/// What a tile with no picture shows: a gradient of its own, and its name.
///
/// Two grey letters on a grey card was the whole tile for any title nothing
/// knew a picture for — a wall of them read as a page that had failed to load.
/// The colours come from the name, so a title wears the same ones every time
/// and two titles side by side rarely match.
///
/// The hash is FNV-1a: stable across runs and platforms, which the standard
/// library's hasher deliberately is not.
fn placeholder(painter: &egui::Painter, rect: Rect, name: &str, opacity: f32) {
let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
});
let hue = (hash % 360) as f32 / 360.0;
let light = theme::palette().light;
let (saturation, value) = if light { (0.25, 0.92) } else { (0.45, 0.34) };
let from: Color32 = egui::ecolor::Hsva::new(hue, saturation, value, 1.0).into();
let to: Color32 =
egui::ecolor::Hsva::new((hue + 0.12) % 1.0, saturation, value * 0.65, 1.0).into();
theme::ramp_fill(
painter,
rect,
CornerRadius::same(8),
(from, to),
theme::Direction::Vertical,
opacity,
);
let ink = if light {
Color32::from_black_alpha(170)
} else {
Color32::from_white_alpha(215)
}
.gamma_multiply(opacity);
let mut job = egui::text::LayoutJob::simple(
name.to_owned(),
theme::Role::Heading.font(),
ink,
rect.width() - 28.0,
);
job.wrap.max_rows = 3;
job.wrap.overflow_character = Some('…');
job.halign = egui::Align::Center;
let galley = painter.layout_job(job);
painter.galley(
egui::pos2(rect.center().x, rect.center().y - galley.size().y / 2.0),
galley,
ink,
);
}
/// How a grid divides the width it was given.
@@ -567,13 +816,6 @@ mod tests {
);
}
#[test]
fn initials_take_two_letters() {
assert_eq!(initials("Cowboy Bebop"), "CB");
assert_eq!(initials("Akira"), "A");
assert_eq!(initials(""), "");
}
#[test]
fn column_count_never_reaches_zero() {
assert_eq!(columns(0.0).columns, 1);
+112 -46
View File
@@ -123,6 +123,12 @@ pub struct UpNextCard {
}
impl Overlay {
/// How much of the bottom of the window the controls take, the last time
/// they were drawn — so things floating over the picture can keep clear.
pub fn covered(&self) -> f32 {
self.chrome_height
}
/// Fold this frame's pointer into the overlay's timer.
pub fn observe(&mut self, ctx: &egui::Context) {
let (now, pointer) = ctx.input(|input| (input.time, input.pointer.latest_pos()));
@@ -208,6 +214,11 @@ pub fn show(
actions.push(Action::Player(Command::TogglePause));
}
if response.double_clicked() {
// The first half of a double click already paused, and a viewer who
// double-clicked asked for fullscreen, not for a pause — so the second
// half takes the pause back. Waiting out the double-click delay before
// pausing would make every single click feel late instead.
actions.push(Action::Player(Command::TogglePause));
actions.push(Action::ToggleFullscreen);
}
@@ -226,16 +237,31 @@ pub fn show(
None => skip(ui, playback, rect, float_above, actions),
}
if visible {
overlay.chrome_height = chrome(
ui,
playback,
neighbours,
rect,
overlay.chrome_height,
actions,
);
} else {
// Last frame's height, like everything else that has to clear the controls.
playback.lift_subtitles(
if visible { overlay.chrome_height } else { 0.0 },
rect.height(),
);
// Faded rather than switched, both ways: controls that blink out under a
// reading eye are more distracting than the ones that were there.
let fade = ctx.animate_bool_with_time(ui.id().with("chrome"), visible, 0.18);
if fade > 0.0 {
overlay.chrome_height = ui
.scope(|ui| {
ui.multiply_opacity(fade);
chrome(
ui,
playback,
neighbours,
rect,
overlay.chrome_height,
actions,
)
})
.inner;
}
if !visible {
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
// The controls have to disappear on their own, and nothing else is
// going to cause a frame while a film plays without the mouse moving.
@@ -610,28 +636,14 @@ fn chrome(
}
/// The gradient behind the title strip, fading downwards out of the top edge.
///
/// Drawn as a few bands rather than a real gradient — egui has no mesh gradient
/// primitive, and at this size the banding is invisible.
fn scrim_top(ui: &egui::Ui, rect: Rect) {
const BANDS: usize = 10;
let painter = ui.painter();
let band = rect.height() / BANDS as f32;
for index in 0..BANDS {
let fraction = 1.0 - index as f32 / (BANDS - 1) as f32;
let strip = Rect::from_min_size(
egui::pos2(rect.left(), rect.top() + band * index as f32),
Vec2::new(rect.width(), band + 1.0),
);
painter.rect_filled(
strip,
CornerRadius::ZERO,
// Squared, so the fade is dark where the text is and gone well
// before the middle of the picture — a linear ramp over a strip
// this tall greys the whole top third of the frame.
Color32::from_black_alpha((fraction * fraction * 225.0) as u8),
);
}
// Squared, so the fade is dark where the text is and gone well before the
// middle of the picture — a linear ramp over a strip this tall greys the
// whole top third of the frame.
fade(ui.painter(), rect, |t| {
let fraction = 1.0 - t;
fraction * fraction * 225.0
});
}
/// The wash behind the controls: flat over the whole strip, fading in above it.
@@ -642,27 +654,81 @@ fn scrim_top(ui: &egui::Ui, rect: Rect) {
/// strip itself is one flat wash, and the ramp happens in the empty picture
/// above it, where there is nothing to obscure.
fn scrim_bottom(ui: &egui::Ui, strip: Rect) {
const BANDS: usize = 12;
let painter = ui.painter();
painter.rect_filled(
strip,
CornerRadius::ZERO,
Color32::from_black_alpha(SCRIM_ALPHA as u8),
);
let above = Rect::from_min_max(
egui::pos2(strip.left(), strip.top() - SCRIM_FADE),
egui::pos2(strip.right(), strip.top()),
);
// Squared again: the wash should meet the strip at full strength and be
// gone within a finger's width of picture above it.
fade(painter, above, |t| t * t * SCRIM_ALPHA);
}
let band = SCRIM_FADE / BANDS as f32;
for index in 0..BANDS {
let fraction = (index + 1) as f32 / BANDS as f32;
let rect = Rect::from_min_size(
egui::pos2(strip.left(), strip.top() - SCRIM_FADE + band * index as f32),
Vec2::new(strip.width(), band + 1.0),
);
painter.rect_filled(
rect,
CornerRadius::ZERO,
// Squared again: the wash should meet the strip at full strength
// and be gone within a finger's width of picture above it.
Color32::from_black_alpha((fraction * fraction * SCRIM_ALPHA) as u8),
);
/// Black over `rect`, at the alpha `curve` gives for each fraction of the way
/// down it.
///
/// One mesh with a colour per row of vertices, which the GPU interpolates
/// between. It replaced a stack of flat bands, and the bands were what showed:
/// each overlapped the next by a pixel so no gap could open between them, and
/// every overlap was a line of double darkness across the picture.
fn fade(painter: &egui::Painter, rect: Rect, curve: impl Fn(f32) -> f32) {
/// Enough rows that the curve between them reads as a curve.
const ROWS: u32 = 16;
let mut mesh = egui::Mesh::default();
for row in 0..=ROWS {
let t = row as f32 / ROWS as f32;
let y = egui::lerp(rect.top()..=rect.bottom(), t);
let colour = Color32::from_black_alpha(curve(t).clamp(0.0, 255.0) as u8);
mesh.colored_vertex(egui::pos2(rect.left(), y), colour);
mesh.colored_vertex(egui::pos2(rect.right(), y), colour);
if row > 0 {
let base = (row - 1) * 2;
mesh.add_triangle(base, base + 1, base + 2);
mesh.add_triangle(base + 1, base + 3, base + 2);
}
}
painter.add(egui::Shape::mesh(mesh));
}
/// What a key just did, in a pill near the top of the picture, for a moment.
///
/// Returns whether it is still showing.
pub fn osd(ctx: &egui::Context, text: &str, at: f64) -> bool {
/// How long it stays, and how much of that is fading out.
const SECONDS: f64 = 0.9;
const FADE: f64 = 0.25;
let age = ctx.input(|input| input.time) - at;
if age >= SECONDS {
return false;
}
let opacity = ((SECONDS - age) / FADE).clamp(0.0, 1.0) as f32;
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("osd"),
));
let galley = painter.layout_no_wrap(text.to_owned(), theme::Role::Title.font(), Color32::WHITE);
let size = galley.size() + Vec2::new(36.0, 18.0);
let screen = ctx.content_rect();
let frame = Rect::from_center_size(
egui::pos2(screen.center().x, screen.top() + TITLE_HEIGHT + 40.0),
size,
);
painter.rect_filled(
frame,
CornerRadius::same((size.y / 2.0) as u8),
Color32::from_black_alpha((190.0 * opacity) as u8),
);
painter.galley_with_override_text_color(
frame.min + Vec2::new(18.0, 9.0),
galley,
Color32::WHITE.gamma_multiply(opacity),
);
ctx.request_repaint();
true
}
+256 -151
View File
@@ -20,9 +20,14 @@ use crate::ui;
#[derive(Debug, Clone, Copy)]
pub struct Prefs {
pub autoplay: bool,
pub auto_skip: bool,
pub appearance: Appearance,
}
/// 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,
@@ -33,13 +38,21 @@ pub fn show(
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
appearance_form(ui, prefs.appearance, actions);
ui.add_space(28.0);
playback_form(ui, prefs.autoplay, actions);
ui.add_space(28.0);
metadata_form(ui, settings, api_key, actions);
// 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(8.0);
appearance_form(ui, prefs.appearance, actions);
playback_form(ui, prefs, actions);
metadata_form(ui, settings, api_key, actions);
about(ui);
});
});
});
}
@@ -49,19 +62,14 @@ pub fn show(
/// 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::section(ui, "Appearance");
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
ui.label(ui::muted("Palette"));
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
ui.add_space(12.0);
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
ui.add_space(8.0);
ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0);
ui.horizontal_wrapped(|ui| {
for flavor in Flavor::ALL {
if ui
.radio(appearance.flavor == flavor, flavor.label())
if palette_card(ui, appearance, flavor)
.on_hover_text(flavor.description())
.clicked()
&& appearance.flavor != flavor
@@ -72,35 +80,114 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
}));
}
}
});
ui.add_space(14.0);
ui.spacing_mut().item_spacing.y = 0.0;
ui.add_space(12.0);
ui.label(ui::muted("Accent"));
ui.horizontal_wrapped(|ui| {
for accent in Accent::ALL {
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
actions.push(Action::SetAppearance(Appearance {
accent,
..appearance
}));
}
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
}));
}
});
ui.add_space(12.0);
let mut gradients = appearance.gradients;
if ui
.checkbox(&mut gradients, "Fade the accent into a second colour")
.on_hover_text(
"Off draws it flat — better on a panel that bands a gradient into stripes",
)
.changed()
{
actions.push(Action::SetAppearance(Appearance {
gradients,
..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(), 0.10);
let painter = ui.painter();
let radius = egui::CornerRadius::same(8);
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(3), 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(3), &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.
@@ -109,23 +196,22 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
/// 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::new(52.0, 26.0);
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(6);
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().rect_stroke(
rect.expand(2.0),
egui::CornerRadius::same(8),
ui.painter().circle_stroke(
rect.center(),
rect.width() / 2.0 + 3.0,
egui::Stroke::new(2.0, theme::text()),
egui::StrokeKind::Outside,
);
}
}
@@ -134,34 +220,61 @@ fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Re
.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// The handful of playback settings that are not per-file.
/// The playback settings that are not per-file.
///
/// Volume, audio language and subtitle language are all set from the player
/// itself, where a viewer can hear the result — only autoplay has no natural
/// home there, because by the time it matters the episode is over.
fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec<Action>) {
ui::section(ui, "Playback");
/// 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));
}
},
);
},
);
}
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
let mut enabled = autoplay;
if ui
.checkbox(&mut enabled, "Play the next episode automatically")
.on_hover_text("Only when an episode reaches its end, never after a failure")
.changed()
{
actions.push(Action::SetAutoplay(enabled));
}
ui.label(ui::muted(
"Volume, audio language and subtitles are set from the player, and remembered \
from there.",
));
/// 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.
@@ -176,94 +289,86 @@ fn metadata_form(
api_key: &mut String,
actions: &mut Vec<Action>,
) {
ui::section(ui, "Posters and descriptions");
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
ui.label(ui::muted(
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
ui::widgets::settings_group(ui, "Posters and descriptions", None, |ui| {
ui.add_space(12.0);
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(10.0);
));
ui.add_space(10.0);
let mut changed = false;
let mut enabled = settings.enabled;
if ui
.checkbox(&mut enabled, "Look up posters and descriptions")
.changed()
{
settings.enabled = enabled;
changed = true;
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(10.0);
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.enabled {
if settings.provider.needs_api_key() {
ui.add_space(10.0);
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(10.0);
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(12.0);
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
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 });
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),
});
}
});
} else {
ui.add_space(6.0);
ui.label(ui::muted(
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
already stored.",
"Stored in your system keyring, never in a config file. Leave the box \
empty and save to forget it.",
));
}
if changed {
actions.push(Action::SetMetadataConfig(settings.clone()));
}
});
ui.add_space(12.0);
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(6.0);
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(12.0);
});
}
+128 -21
View File
@@ -7,29 +7,43 @@
//! typed.
use pstr_core::Share;
use pstr_core::library::Library;
use crate::app::{Action, ShareForm};
use crate::theme;
use crate::ui;
pub fn show(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
pub fn show(
ui: &mut egui::Ui,
shares: &[Share],
library: &Library,
form: &mut ShareForm,
actions: &mut Vec<Action>,
) {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui::section(ui, "Shares");
if shares.is_empty() {
ui.label(ui::muted("No shares yet."));
ui.label(ui::muted(
"No shares yet. Add one below and its contents become your library.",
));
}
for share in shares {
share_row(ui, share, actions);
let titles = library
.titles
.iter()
.filter(|title| title.share_ids.contains(&share.id))
.count();
share_row(ui, share, titles, actions);
}
ui.add_space(22.0);
add_form(ui, form, actions);
add_form(ui, shares, form, actions);
});
}
fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<Action>) {
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
@@ -39,12 +53,13 @@ fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(egui::RichText::new(&share.name).strong());
let detail = if share.has_custom_password {
format!("{} · custom password", share.id)
} else {
share.id.clone()
};
ui.label(ui::muted(detail));
// What is in it, rather than what it is called inside the
// app. The id is still a hover away for a bug report.
let mut detail = ui::library::plural(titles, "title");
if share.has_custom_password {
detail.push_str(" · password protected");
}
ui.label(ui::muted(detail)).on_hover_text(&share.id);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -64,7 +79,7 @@ fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
ui.add_space(6.0);
}
fn add_form(ui: &mut egui::Ui, 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.label(ui::muted(
"Paste the whole link, including everything after the # — that part is the key that \
@@ -88,15 +103,40 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
ui.add_space(8.0);
ui.label(ui::muted("Link"));
ui.add(
egui::TextEdit::singleline(&mut form.url)
.password(true)
.hint_text("https://drive.proton.me/urls/…#…")
.desired_width(f32::INFINITY),
);
ui.horizontal(|ui| {
let paste = 60.0;
let field = egui::Id::new("share-link");
ui.add(
egui::TextEdit::singleline(&mut form.url)
.id(field)
.password(true)
.hint_text("https://drive.proton.me/urls/…#…")
.desired_width(ui.available_width() - paste),
);
// The link is masked, so pasting is the only sensible way in —
// and a button says that more plainly than the field does.
if ui
.add_sized(
[paste - 8.0, ui.spacing().interact_size.y],
egui::Button::new("Paste"),
)
.clicked()
{
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestPaste);
// The paste lands in whatever has focus, so give it some.
ui.memory_mut(|memory| memory.request_focus(field));
}
});
// Said while typing, not after a refused submit: everything this
// checks is knowable from the text alone.
let problem = link_problem(&form.url, shares);
if let Some(problem) = &problem {
ui.label(theme::Role::Caption.rich(problem).color(theme::danger()));
}
ui.add_space(8.0);
ui.checkbox(&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 {
ui.add_space(6.0);
ui.add(
@@ -110,8 +150,14 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
ui.add_space(12.0);
let ready = !form.name.trim().is_empty()
&& !form.url.trim().is_empty()
&& (!form.has_password || !form.password.is_empty());
&& problem.is_none()
&& (!form.has_password || !form.password.is_empty())
&& !form.sending;
if let Some(error) = &form.error {
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
ui.add_space(6.0);
}
ui.horizontal(|ui| {
if ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
@@ -127,9 +173,70 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
.filter(|password| !password.is_empty()),
});
}
if !ready {
if form.sending {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted("adding…"));
} else if form.name.trim().is_empty() || form.url.trim().is_empty() {
ui.label(ui::muted("A name and a link are needed."));
}
});
});
}
/// What is wrong with a link as typed, if anything.
///
/// Nothing for an empty box — that is "not done yet", which the button already
/// says.
fn link_problem(url: &str, shares: &[Share]) -> Option<String> {
let url = url.trim();
if url.is_empty() {
return None;
}
match pstr_core::shares::share_token(url) {
Ok(token) if shares.iter().any(|share| share.token == token) => {
Some("That share is already added.".into())
}
Ok(_) => None,
Err(error) => Some(error.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn share(token: &str) -> Share {
Share {
id: format!("share-{token}"),
name: "anime".into(),
token: token.into(),
has_custom_password: false,
}
}
#[test]
fn an_empty_link_is_not_yet_a_problem() {
assert_eq!(link_problem(" ", &[]), None);
}
#[test]
fn a_link_without_its_key_is_refused_while_typing() {
assert!(link_problem("https://drive.proton.me/urls/ABC123", &[]).is_some());
assert!(link_problem("https://example.com/", &[]).is_some());
assert_eq!(
link_problem("https://drive.proton.me/urls/ABC123#key", &[]),
None
);
}
#[test]
fn a_share_already_added_is_refused_while_typing() {
assert!(
link_problem(
"https://drive.proton.me/urls/ABC123#key",
&[share("ABC123")]
)
.is_some()
);
}
}
+80
View File
@@ -0,0 +1,80 @@
//! The list of keyboard shortcuts, on `?`.
//!
//! Written out here rather than generated from the handlers, because the
//! handlers are split between the player page and every other page, and what a
//! viewer needs is one list grouped by where they are.
use crate::theme;
use crate::ui;
const GROUPS: &[(&str, &[(&str, &str)])] = &[
(
"Anywhere",
&[
("Ctrl F or /", "Search the library"),
("F5 or Ctrl R", "Crawl every share again"),
("Ctrl 1 – 4", "Library, Shares, Downloads, Settings"),
("Ctrl ,", "Settings"),
("Alt ← or mouse back", "Back"),
("?", "This list"),
],
),
(
"Playing",
&[
("Space or K", "Pause and play"),
("← / →", "Back 10 s, forward 30 s"),
("↑ / ↓", "Volume"),
("M", "Mute"),
("N / P", "Next and previous episode"),
("[ / ]", "Slower and faster"),
("S", "Skip the opening or the credits"),
("F or double-click", "Fullscreen"),
("Esc", "Leave fullscreen, then the player"),
],
),
];
/// Draw the list. Returns whether it asked to be closed.
pub fn show(ctx: &egui::Context) -> bool {
let mut close = false;
let modal = egui::Modal::new(egui::Id::new("shortcuts"))
.frame(
egui::Frame::new()
.fill(theme::surface())
.inner_margin(egui::Margin::same(20))
.corner_radius(egui::CornerRadius::same(12)),
)
.show(ctx, |ui| {
ui.set_width(460.0);
ui.horizontal(|ui| {
ui.label(theme::Role::Heading.rich("Keyboard shortcuts").strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() {
close = true;
}
});
});
for (heading, keys) in GROUPS {
ui.add_space(12.0);
ui.label(ui::muted(heading.to_uppercase()));
ui.add_space(4.0);
egui::Grid::new(*heading)
.num_columns(2)
.spacing([18.0, 8.0])
.show(ui, |ui| {
for (key, what) in *keys {
ui.label(
theme::Role::Label
.rich(*key)
.monospace()
.color(theme::accent()),
);
ui.label(theme::Role::Body.rich(*what));
ui.end_row();
}
});
}
});
close || modal.should_close()
}
+502 -215
View File
@@ -30,10 +30,10 @@ pub fn show(
return;
};
if ui.button("Library").clicked() {
if back_link(ui).clicked() {
actions.push(Action::Goto(Page::Library));
}
ui.add_space(10.0);
ui.add_space(6.0);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
@@ -41,54 +41,142 @@ pub fn show(
header(ui, art, title, &offline, actions);
ui.add_space(18.0);
for (index, season) in title.seasons.iter().enumerate() {
let open = index == 0 || title.seasons.len() == 1;
egui::CollapsingHeader::new(
theme::Role::Subhead
.rich(format!(
"{} · {}",
season.label(),
ui::library::plural(season.episodes.len(), "episode")
))
.strong(),
)
.id_salt(("season", index))
.default_open(open)
.show(ui, |ui| {
let season_keys: Vec<_> = season.episodes.iter().map(key_of).collect();
let all_offline = season_keys.iter().all(|key| offline.files.contains(key));
if all_offline {
if ui
.button("Delete season")
.on_hover_text("Delete the offline copies; keep the online source")
.clicked()
{
for key in season_keys {
actions.push(Action::RemoveDownload(key, false));
}
}
} else if ui
.button("Download season")
.on_hover_text("Download every episode in this season")
.clicked()
{
actions.push(Action::MakeOffline(
season
.episodes
.iter()
.map(|episode| PlaybackTarget::new(title, episode))
.collect(),
));
}
for episode in &season.episodes {
episode_row(ui, art, title, season, episode, &offline, actions);
// One season at a time, picked from a row of pills, rather than
// every season stacked as a collapsing header: a four-season show
// was a page of headers to scroll past to reach the one being
// watched. The one opened first is the one with the next episode.
let picked_id = ui.id().with(("season", &title.key));
let default = title
.next_up()
.and_then(|next| {
title.seasons.iter().position(|season| {
season
.episodes
.iter()
.any(|episode| episode.node.link_id == next.node.link_id)
})
})
.unwrap_or(0);
let mut picked = ui
.data(|data| data.get_temp::<usize>(picked_id))
.unwrap_or(default)
.min(title.seasons.len().saturating_sub(1));
let Some(season) = title.seasons.get(picked) else {
return;
};
ui.horizontal(|ui| {
if title.seasons.len() > 1 {
let labels: Vec<String> =
title.seasons.iter().map(|season| season.label()).collect();
let choices: Vec<(usize, &str)> = labels
.iter()
.enumerate()
.map(|(index, label)| (index, label.as_str()))
.collect();
if let Some(index) = ui::widgets::segmented(ui, picked, &choices) {
picked = index;
ui.data_mut(|data| data.insert_temp(picked_id, index));
}
ui.add_space(10.0);
}
ui.label(ui::muted(ui::library::plural(
season.episodes.len(),
"episode",
)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
season_download(ui, title, season, &offline, actions);
});
ui.add_space(6.0);
});
ui.add_space(10.0);
let Some(season) = title.seasons.get(picked) else {
return;
};
for episode in &season.episodes {
episode_row(ui, art, title, season, episode, &offline, actions);
}
ui.add_space(12.0);
});
}
/// A title's poster, at the poster's own shape, with how far into it the
/// viewer is along its foot.
fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64>) {
let size = egui::vec2(180.0, 270.0);
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
let radius = egui::CornerRadius::same(10);
ui.painter()
.add(theme::tile_shadow(1.0).as_shape(rect, radius));
ui.painter().add(
egui::epaint::RectShape::filled(rect, radius, egui::Color32::WHITE)
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), size)),
);
if let Some(progress) = progress {
let track = egui::Rect::from_min_max(
egui::pos2(rect.left(), rect.bottom() - 4.0),
rect.right_bottom(),
);
let painter = ui.painter().with_clip_rect(track);
painter.rect_filled(rect, radius, egui::Color32::from_black_alpha(150));
let mut played = rect;
played.set_width(rect.width() * progress.clamp(0.0, 1.0) as f32);
theme::accent_fill(&painter, played, radius, 0.0);
}
}
/// "← Library", as a link rather than a button: it is navigation, and a
/// filled button beside the page's real actions competed with them.
fn back_link(ui: &mut egui::Ui) -> egui::Response {
let text = theme::Role::Label.rich("← Library");
let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
if response.hovered() {
ui.painter().hline(
response.rect.x_range(),
response.rect.bottom(),
egui::Stroke::new(1.0, theme::muted()),
);
}
response
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("Back (Esc, Alt ←)")
}
/// Download, or delete, a whole season.
fn season_download(
ui: &mut egui::Ui,
title: &Title,
season: &Season,
offline: &OfflineView<'_>,
actions: &mut Vec<Action>,
) {
let season_keys: Vec<_> = season.episodes.iter().map(key_of).collect();
let all_offline = season_keys.iter().all(|key| offline.files.contains(key));
if all_offline {
if ui
.button("Delete season")
.on_hover_text("Delete the offline copies; keep the online source")
.clicked()
{
for key in season_keys {
actions.push(Action::RemoveDownload(key, false));
}
}
} else if ui
.button("Download season")
.on_hover_text("Download every episode in this season")
.clicked()
{
actions.push(Action::MakeOffline(
season
.episodes
.iter()
.map(|episode| PlaybackTarget::new(title, episode))
.collect(),
));
}
}
/// The still, the name and the one button that matters.
fn header(
ui: &mut egui::Ui,
@@ -115,20 +203,32 @@ fn header(
ui.add_space(6.0);
ui.horizontal_top(|ui| {
ui::card(
ui,
Card {
art: picture,
name: &title.name,
subtitle: String::new(),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: None,
width: theme::CARD_WIDTH,
},
);
match &picture {
// A poster is drawn as a poster. Fitted into the 16:9 tile the grid
// uses, it was a narrow strip of picture between two bars.
Some((texture, pstr_core::metadata::ArtShape::Portrait)) => {
poster(ui, texture, title.resume().and_then(|e| e.progress()));
}
_ => {
ui::card(
ui,
Card {
art: picture,
name: &title.name,
subtitle: String::new(),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: None,
width: theme::CARD_WIDTH,
},
);
}
}
ui.add_space(18.0);
ui.add_space(22.0);
ui.vertical(|ui| {
// Text in a column a person can read, with the picture to the
// right of it rather than under it.
ui.set_max_width(ui.available_width().min(820.0));
ui.label(theme::Role::Display.rich(&title.name).strong());
// The provider's name for it, when it is not the one the files use.
// Worth showing rather than replacing the filename's: a viewer
@@ -149,9 +249,17 @@ fn header(
ui.horizontal(|ui| {
if let Some(episode) = title.next_up() {
let resuming = episode.resume_at().is_some();
// Where it resumes, in the button that resumes it. As a
// label of its own it ended up three controls further
// along, after the download buttons, reading as a fact
// about those.
let at = episode
.resume_at()
.map(|at| format!(" · {}", ui::format_time(at)))
.unwrap_or_default();
let label = match (resuming, episode.numbering()) {
(true, Some(numbering)) => format!("Resume {numbering}"),
(true, None) => "Resume".to_string(),
(true, Some(numbering)) => format!("Resume {numbering}{at}"),
(true, None) => format!("Resume{at}"),
(false, Some(numbering)) if title.kind == TitleKind::Series => {
format!("Play {numbering}")
}
@@ -160,6 +268,19 @@ fn header(
if ui::accent_button(ui, &label).clicked() {
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
// Beside the button it is the alternative to.
if resuming
&& ui
.button("Start over")
.on_hover_text("Play from the beginning")
.clicked()
{
actions.push(Action::Play(PlaybackTarget::from_node(
title,
&episode.node,
None,
)));
}
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 {
@@ -202,20 +323,6 @@ fn header(
} else if all_offline {
ui.label(ui::muted("Available offline"));
}
if resuming && let Some(at) = episode.resume_at() {
ui.label(ui::muted(format!("at {}", ui::format_time(at))));
if ui
.button("Start over")
.on_hover_text("Play from the beginning")
.clicked()
{
actions.push(Action::Play(PlaybackTarget::from_node(
title,
&episode.node,
None,
)));
}
}
}
// The way out of a match the scorer would not make, or made
@@ -299,7 +406,10 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
egui::epaint::RectShape::filled(
rect,
egui::CornerRadius::ZERO,
theme::background().gamma_multiply(if ramps { 0.90 } else { 0.94 }),
// Lighter with the ramps on: the horizontal one below is solid
// where the text is, so this coat only has to calm the picture's
// right side, not make it disappear.
theme::background().gamma_multiply(if ramps { 0.72 } else { 0.94 }),
)
.into(),
);
@@ -340,11 +450,50 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
/// What the provider had to say. Only ever drawn under a match.
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
if !found.genres.is_empty() {
ui.label(ui::muted(found.genres.join(" · ")));
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for genre in &found.genres {
ui::widgets::chip(ui, genre);
}
});
ui.add_space(8.0);
}
if let Some(overview) = &found.overview {
ui.add(egui::Label::new(theme::Role::Label.rich(overview).color(theme::text())).wrap());
// Four lines, then "More". A provider's synopsis runs to three
// paragraphs on a long show, and all of them pushed the episodes —
// what the page is for — below the fold.
const LINES: usize = 4;
let id = ui.id().with(("overview", &found.remote_id));
let open = ui.data(|data| data.get_temp::<bool>(id)).unwrap_or(false);
let mut job = egui::text::LayoutJob::simple(
overview.clone(),
theme::Role::Label.font(),
theme::text(),
ui.available_width(),
);
if !open {
job.wrap.max_rows = LINES;
job.wrap.overflow_character = Some('…');
}
let galley = ui.fonts_mut(|fonts| fonts.layout_job(job));
let clipped = galley.elided;
ui.label(galley);
if clipped || open {
let toggle = ui.add(
egui::Label::new(
theme::Role::Caption
.rich(if open { "Less" } else { "More" })
.color(theme::accent()),
)
.sense(egui::Sense::click()),
);
if toggle
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
ui.data_mut(|data| data.insert_temp(id, !open));
}
}
}
if let Some(url) = &found.url {
ui.add_space(8.0);
@@ -398,7 +547,11 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
parts.join(" · ")
}
/// One line per file: click anywhere on it to play.
/// How big an episode's still is drawn.
const STILL: egui::Vec2 = egui::vec2(160.0, 90.0);
/// One row per file: its still, its name and what it is about, and the two
/// things that can be done to it besides playing it.
fn episode_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
@@ -417,158 +570,292 @@ fn episode_row(
)
});
let watched = episode.is_watched();
egui::Frame::new()
.fill(if watched {
theme::background()
} else {
theme::card()
})
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin::symmetric(10, 8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
// An explicit target rather than a clickable row: the row also
// carries a checkbox, and a click that means "seen" must never
// be one that starts a 1.4 GiB stream instead.
if ui
.add(egui::Button::new(theme::Role::Label.rich("▶")).fill(theme::card_hover()))
.on_hover_text("Play")
.clicked()
{
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
let key = key_of(episode);
let current = offline.downloads.iter().find(|item| item.key == key);
if offline.files.contains(&key) {
if ui
.small_button("Delete")
.on_hover_text("Delete the offline copy; keep the online source")
.clicked()
{
actions.push(Action::RemoveDownload(key.clone(), false));
}
ui.label(ui::muted("✓ Offline"));
} else if let Some(item) = current {
match item.state {
DownloadState::Running | DownloadState::Queued => {
if ui.small_button("Pause").clicked() {
actions.push(Action::PauseDownload(key.clone()));
}
ui.label(ui::muted(format!("↓ {:.0}%", item.percent() * 100.0)));
}
DownloadState::Paused => {
if ui.small_button("Resume").clicked() {
actions.push(Action::ResumeDownload(key.clone()));
}
ui.label(ui::muted("Paused · partial kept"));
}
DownloadState::Cancelled | DownloadState::Failed(_) => {
if ui.small_button("Resume").clicked() {
actions.push(Action::ResumeDownload(key.clone()));
}
ui.label(ui::muted("Partial"));
}
DownloadState::Completed => {
ui.label(ui::muted("✓ Offline"));
}
}
} else if ui
.small_button("Download")
.on_hover_text("Make available offline")
.clicked()
{
actions.push(Action::MakeOffline(vec![PlaybackTarget::new(
title, episode,
)]));
}
let row_id = ui.id().with(("episode", &episode.node.link_id));
let numbering = episode
.numbering()
.or_else(|| season.number.map(|number| format!("S{number:02}")))
.unwrap_or_default();
ui.add_sized(
[66.0, 18.0],
egui::Label::new(
egui::RichText::new(numbering)
.monospace()
.color(theme::muted()),
),
);
let frame = egui::Frame::new()
.corner_radius(egui::CornerRadius::same(10))
.inner_margin(egui::Margin::same(8));
let mut prepared = frame.begin(ui);
{
let ui = &mut prepared.content_ui;
ui.set_width(ui.available_width());
ui.horizontal_top(|ui| {
still(ui, art, title, episode, watched, actions);
ui.add_space(6.0);
let controls = 96.0;
let text_width = (ui.available_width() - controls).max(120.0);
ui.vertical(|ui| {
ui.set_width(text_width);
ui.spacing_mut().item_spacing.y = 3.0;
// The provider's name for the episode, when there is one:
// "The Immortal Legion" reads as an episode, and
// "[Reaktor] … E57 v2 [1080p][x265].mkv" reads as a filename.
let numbering = episode
.numbering()
.or_else(|| season.number.map(|number| format!("S{number:02}")))
.unwrap_or_default();
let name = found
.as_ref()
.and_then(|(name, _, _)| name.clone())
.unwrap_or_else(|| episode.detail().to_string());
let label = egui::RichText::new(name).color(if watched {
theme::muted()
} else {
theme::text()
});
let hover = match &found {
// The synopsis, where the provider has one — AniList does
// not, and the filename is worth showing either way.
Some((_, Some(overview), _)) => {
format!("{}\n\n{overview}", episode.node.name)
}
_ => episode.node.name.clone(),
};
ui.add(egui::Label::new(label).truncate())
.on_hover_text(hover);
if let Some((_, _, Some(air_date))) = &found {
ui.label(ui::muted(air_date.clone()));
let mut job = egui::text::LayoutJob::default();
if !numbering.is_empty() {
job.append(
&format!("{numbering} "),
0.0,
egui::TextFormat::simple(egui::FontId::monospace(13.0), theme::muted()),
);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut checked = watched;
if ui
.checkbox(&mut checked, "")
.on_hover_text(if watched {
"Mark unwatched"
job.append(
&name,
0.0,
egui::TextFormat::simple(
theme::Role::Body.font(),
if watched {
theme::muted()
} else {
"Mark watched"
})
.changed()
{
actions.push(Action::SetWatched {
share_id: episode.node.share_id.clone(),
link_id: episode.node.link_id.clone(),
watched: checked,
duration: episode.watch.and_then(|watch| watch.duration_secs),
});
}
theme::text()
},
),
);
job.wrap.max_rows = 1;
job.wrap.overflow_character = Some('…');
ui.add(egui::Label::new(job).truncate())
.on_hover_text(&episode.node.name);
match episode.node.size {
// A file the share says is empty is one that will not
// play — an upload that never finished, usually. Worth
// saying so on the row rather than only when a click on
// it comes back with "has no content".
Some(0) => {
ui.label(theme::Role::Caption.rich("empty").color(theme::danger()))
.on_hover_text(
"The share reports no content for this file; it cannot be played.",
);
}
Some(size) => {
ui.label(ui::muted(ui::format_size(size)));
}
None => {}
}
if let Some(at) = episode.resume_at() {
ui.label(
theme::Role::Caption
.rich(format!("resume {}", ui::format_time(at)))
.color(theme::accent()),
);
}
});
ui.label(ui::muted(details(
episode,
found.as_ref().and_then(|f| f.2.as_deref()),
)));
if let Some((_, Some(overview), _)) = &found {
let mut job = egui::text::LayoutJob::simple(
overview.clone(),
theme::Role::Caption.font(),
theme::muted(),
text_width,
);
job.wrap.max_rows = 2;
job.wrap.overflow_character = Some('…');
ui.add(egui::Label::new(job)).on_hover_text(overview);
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.set_min_height(STILL.y);
if ui::widgets::watched_mark(ui, watched)
.on_hover_text(if watched {
"Mark unwatched"
} else {
"Mark watched"
})
.clicked()
{
actions.push(Action::SetWatched {
share_id: episode.node.share_id.clone(),
link_id: episode.node.link_id.clone(),
watched: !watched,
duration: episode.watch.and_then(|watch| watch.duration_secs),
});
}
ui.add_space(6.0);
download(ui, title, episode, offline, actions);
});
});
ui.add_space(4.0);
}
// The row lights up under the pointer, so it is clear which file the
// buttons on the right belong to in a list of thirty.
let response = prepared.allocate_space(ui);
let lit = ui.ctx().animate_bool_with_time(
row_id,
response.hovered() || response.contains_pointer(),
0.10,
);
prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit);
prepared.paint(ui);
ui.add_space(2.0);
}
/// The grey line under an episode's name: when it aired, how big it is, and
/// where it would resume.
fn details(episode: &Episode, air_date: Option<&str>) -> String {
let mut parts = Vec::new();
if let Some(at) = episode.resume_at() {
let left = episode
.watch
.and_then(|watch| watch.duration_secs)
.map(|duration| format!(" · {} left", ui::format_time(duration - at)))
.unwrap_or_default();
parts.push(format!("Resume at {}{left}", ui::format_time(at)));
} else if episode.is_watched() {
parts.push("Watched".into());
}
if let Some(date) = air_date {
parts.push(date.to_owned());
}
match episode.node.size {
// A file the share says is empty is one that will not play — an upload
// that never finished, usually. Worth saying on the row rather than
// only when a click on it comes back with "has no content".
Some(0) => parts.push("empty — this file cannot be played".into()),
Some(size) => parts.push(ui::format_size(size)),
None => {}
}
parts.join(" · ")
}
/// The still, which is also the play button.
///
/// An explicit target rather than a clickable row: the row also carries the
/// watched mark and the download button, and a click that meant either of
/// those must never be one that starts a 1.4 GiB stream instead.
fn still(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
episode: &Episode,
watched: bool,
actions: &mut Vec<Action>,
) {
let (rect, response) = ui.allocate_exact_size(STILL, egui::Sense::click());
if !ui.is_rect_visible(rect) {
return;
}
let radius = egui::CornerRadius::same(8);
let painter = ui.painter();
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 tint = if watched {
egui::Color32::from_gray(150)
} else {
egui::Color32::WHITE
};
painter.add(
egui::epaint::RectShape::filled(rect, radius, tint)
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
);
}
let hover = ui
.ctx()
.animate_bool_with_time(response.id, response.hovered(), 0.12);
if hover > 0.0 {
painter.rect_filled(
rect,
radius,
egui::Color32::from_black_alpha((90.0 * hover) as u8),
);
}
// A play mark, always faintly there and full under the pointer.
let centre = rect.center();
painter.circle_filled(
centre,
18.0,
egui::Color32::from_black_alpha((110.0 + 80.0 * hover) as u8),
);
let ink = egui::Color32::WHITE.gamma_multiply(0.75 + 0.25 * hover);
let r = 7.0;
painter.add(egui::Shape::convex_polygon(
vec![
centre + egui::vec2(-r * 0.6, -r),
centre + egui::vec2(r, 0.0),
centre + egui::vec2(-r * 0.6, r),
],
ink,
egui::Stroke::NONE,
));
if let Some(progress) = episode.progress() {
let track = egui::Rect::from_min_max(
egui::pos2(rect.left(), rect.bottom() - 4.0),
rect.right_bottom(),
);
let painter = painter.with_clip_rect(track);
painter.rect_filled(rect, radius, egui::Color32::from_black_alpha(150));
let mut played = rect;
played.set_width(rect.width() * progress.clamp(0.0, 1.0) as f32);
theme::accent_fill(&painter, played, radius, 0.0);
}
if response
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(if episode.resume_at().is_some() {
"Resume"
} else {
"Play"
})
.clicked()
{
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
}
/// One button that is the file's whole offline story: download it, see it
/// coming, pause it, or delete the copy.
fn download(
ui: &mut egui::Ui,
title: &Title,
episode: &Episode,
offline: &OfflineView<'_>,
actions: &mut Vec<Action>,
) {
use ui::widgets::{DownloadGlyph, download_button};
let key = key_of(episode);
let current = offline.downloads.iter().find(|item| item.key == key);
if offline.files.contains(&key) {
if download_button(ui, DownloadGlyph::Done)
.on_hover_text("Available offline. Click to delete the copy; the share keeps the file.")
.clicked()
{
actions.push(Action::RemoveDownload(key, false));
}
return;
}
match current.map(|item| (&item.state, item.percent())) {
Some((DownloadState::Running | DownloadState::Queued, fraction)) => {
if download_button(ui, DownloadGlyph::Progress(fraction))
.on_hover_text(format!(
"Downloading, {:.0}%. Click to pause.",
fraction * 100.0
))
.clicked()
{
actions.push(Action::PauseDownload(key));
}
}
Some((
DownloadState::Paused | DownloadState::Cancelled | DownloadState::Failed(_),
fraction,
)) => {
if download_button(ui, DownloadGlyph::Paused(fraction))
.on_hover_text(format!(
"Stopped at {:.0}%. Click to carry on.",
fraction * 100.0
))
.clicked()
{
actions.push(Action::ResumeDownload(key));
}
}
Some((DownloadState::Completed, _)) => {
download_button(ui, DownloadGlyph::Done).on_hover_text("Available offline");
}
None => {
if download_button(ui, DownloadGlyph::Download)
.on_hover_text("Download, to play without a connection")
.clicked()
{
actions.push(Action::MakeOffline(vec![PlaybackTarget::new(
title, episode,
)]));
}
}
}
}
fn key_of(episode: &Episode) -> DownloadKey {
+207
View File
@@ -0,0 +1,207 @@
//! Short messages in the corner, for a few seconds.
//!
//! They replaced a single status line along the bottom of the window, which had
//! three problems a stack does not: a second message wiped the first before it
//! could be read, an error looked like any other line in a slightly different
//! grey, and the line took a strip of the page with it every time it appeared.
use egui::{Align2, Color32, CornerRadius, Sense, Stroke, Vec2};
use crate::theme;
/// How long a message stays up.
const SECONDS: f64 = 5.0;
/// How long an error stays up. Longer: it is more likely to be read twice.
const ERROR_SECONDS: f64 = 9.0;
/// How many fit at once. A burst beyond that drops the oldest.
const MOST: usize = 4;
/// How long one takes to arrive and to leave.
const FADE: f64 = 0.18;
const WIDTH: f32 = 340.0;
pub struct Toast {
id: u64,
text: String,
error: bool,
at: f64,
}
/// The messages on screen.
#[derive(Default)]
pub struct Toasts {
items: Vec<Toast>,
next: u64,
}
impl Toasts {
pub fn push(&mut self, now: f64, text: impl Into<String>, error: bool) {
let text = text.into();
// The same message again — a retry that failed the same way — restarts
// the one already up rather than stacking a copy of it.
if let Some(existing) = self.items.iter_mut().find(|toast| toast.text == text) {
existing.at = now;
existing.error = error;
return;
}
self.next += 1;
self.items.push(Toast {
id: self.next,
text,
error,
at: now,
});
if self.items.len() > MOST {
self.items.remove(0);
}
}
/// Draw the stack above `bottom` points from the window's bottom edge, and
/// drop whatever has expired or been closed.
pub fn show(&mut self, ctx: &egui::Context, bottom: f32) {
let now = ctx.input(|input| input.time);
let pointer = ctx.input(|input| input.pointer.hover_pos());
let mut closed = None;
let mut hovered = false;
let mut y = ctx.content_rect().bottom() - bottom - 16.0;
let right = ctx.content_rect().right() - 16.0;
// Newest at the bottom, nearest where the eye already is.
for toast in self.items.iter().rev() {
let life = lifetime(toast.error);
let age = now - toast.at;
let arriving = (age / FADE).clamp(0.0, 1.0) as f32;
let leaving = ((life - age) / FADE).clamp(0.0, 1.0) as f32;
let opacity = ease(arriving.min(leaving));
let area = egui::Area::new(egui::Id::new(("toast", toast.id)))
.order(egui::Order::Foreground)
.pivot(Align2::RIGHT_BOTTOM)
.fixed_pos(egui::pos2(right + (1.0 - opacity) * 24.0, y))
.interactable(true)
.show(ctx, |ui| {
ui.set_opacity(opacity);
card(ui, toast)
});
if area.inner {
closed = Some(toast.id);
}
if pointer.is_some_and(|at| area.response.rect.contains(at)) {
hovered = true;
}
y -= area.response.rect.height() + 8.0;
}
if let Some(id) = closed {
self.items.retain(|toast| toast.id != id);
}
// Under the pointer, nothing leaves: someone is reading it.
if hovered {
for toast in &mut self.items {
let life = lifetime(toast.error);
toast.at = toast.at.max(now - life + 1.5);
}
}
self.items
.retain(|toast| now - toast.at < lifetime(toast.error));
if !self.items.is_empty() {
// Enough frames to animate the edges and to notice an expiry.
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
}
}
fn lifetime(error: bool) -> f64 {
if error { ERROR_SECONDS } else { SECONDS }
}
fn ease(t: f32) -> f32 {
1.0 - (1.0 - t).powi(3)
}
/// One message. Returns whether its close button was pressed.
fn card(ui: &mut egui::Ui, toast: &Toast) -> bool {
let mut close = false;
let edge = if toast.error {
theme::danger()
} else {
theme::accent()
};
egui::Frame::new()
.fill(theme::surface())
.stroke(Stroke::new(1.0, theme::card_hover()))
.corner_radius(CornerRadius::same(10))
.inner_margin(egui::Margin {
left: 16,
right: 10,
top: 10,
bottom: 10,
})
.shadow(egui::Shadow {
offset: [0, 6],
blur: 18,
spread: 0,
color: Color32::from_black_alpha(90),
})
.show(ui, |ui| {
ui.set_width(WIDTH);
let frame = ui.max_rect();
ui.horizontal(|ui| {
ui.add(
egui::Label::new(theme::Role::Label.rich(&toast.text).color(theme::text()))
.wrap(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
let (rect, response) =
ui.allocate_exact_size(Vec2::splat(18.0), Sense::click());
let ink = if response.hovered() {
theme::text()
} else {
theme::muted()
};
let d = 4.0;
let c = rect.center();
ui.painter().line_segment(
[c + Vec2::new(-d, -d), c + Vec2::new(d, d)],
Stroke::new(1.5, ink),
);
ui.painter().line_segment(
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
Stroke::new(1.5, ink),
);
close = response.on_hover_text("Dismiss").clicked();
});
});
// A strip down the leading edge says which kind it is before a word
// of it is read.
let strip = egui::Rect::from_min_max(
egui::pos2(frame.left() - 10.0, frame.top() - 4.0),
egui::pos2(frame.left() - 7.0, frame.bottom() + 4.0),
);
ui.painter().rect_filled(strip, CornerRadius::same(2), edge);
});
close
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_repeated_message_restarts_rather_than_stacks() {
let mut toasts = Toasts::default();
toasts.push(0.0, "could not open", true);
toasts.push(3.0, "could not open", true);
assert_eq!(toasts.items.len(), 1);
assert_eq!(toasts.items[0].at, 3.0);
}
#[test]
fn a_burst_keeps_only_the_newest() {
let mut toasts = Toasts::default();
for index in 0..10 {
toasts.push(0.0, format!("message {index}"), false);
}
assert_eq!(toasts.items.len(), MOST);
assert_eq!(toasts.items.last().unwrap().text, "message 9");
}
}
+92 -24
View File
@@ -94,6 +94,8 @@ pub fn full(
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
track_menus(ui, playback, actions);
ui.add_space(8.0);
speed_menu(ui, playback, actions);
ui.add_space(8.0);
chapter_menu(ui, playback, actions);
ui.add_space(8.0);
volume(ui, playback, actions);
@@ -429,6 +431,32 @@ fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions:
}
}
/// How fast it plays. Says so on the button only when it is not normal speed,
/// because that is the one case worth noticing at a glance.
fn speed_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
let label = if (playback.speed - 1.0).abs() < 1e-6 {
"Speed".to_owned()
} else {
format!("Speed · {}", ui::format_speed(playback.speed))
};
ui.menu_button(label, |ui| {
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] {
if ui
.selectable_label(
(playback.speed - speed).abs() < 1e-6,
ui::format_speed(speed),
)
.clicked()
{
actions.push(Action::SetSpeed(speed));
ui.close();
}
}
})
.response
.on_hover_text("Playback speed ([ and ])");
}
/// Jump to a chapter. Only drawn for a file that has more than one, which for
/// most releases means anime and not much else.
fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
@@ -470,66 +498,87 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
let hovered = response.hovered() || response.dragged();
let track = Rect::from_center_size(
rect.center(),
Vec2::new(width, if hovered { thickness + 2.0 } else { thickness }),
);
let active = response.hovered() || response.dragged();
// Grows rather than jumps when the pointer arrives.
let grow = ui
.ctx()
.animate_bool_with_time(response.id.with("grow"), active, 0.12);
let track = Rect::from_center_size(rect.center(), Vec2::new(width, thickness + 3.0 * grow));
let radius = CornerRadius::same(3);
let painter = ui.painter();
painter.rect_filled(track, CornerRadius::same(3), Color32::from_gray(52));
// From the palette rather than a fixed grey: the same bar sits on the
// video's dark scrim and on a light theme's transport strip.
painter.rect_filled(track, radius, theme::muted().gamma_multiply(0.35));
if let Some(progress) = playback.progress() {
let duration = playback.duration.filter(|duration| *duration > 0.0);
let pointer = response
.interact_pointer_pos()
.or_else(|| response.hover_pos())
.map(|position| ((position.x - track.left()) / track.width()).clamp(0.0, 1.0));
// Where the pointer would land, lighter than what has been played: the bar
// shows the jump before it is made.
if let (Some(fraction), true) = (pointer, active) {
let mut ghost = track;
ghost.set_width(track.width() * fraction);
painter.rect_filled(ghost, radius, theme::muted().gamma_multiply(0.45));
}
// While dragging, the knob follows the pointer even though the seek waits
// for the release — see below. A knob that stays put under a moving hand
// reads as a bar that is not listening.
let shown = match (response.dragged(), pointer) {
(true, Some(fraction)) => Some(fraction),
_ => playback.progress(),
};
if let Some(progress) = shown {
let mut played = track;
played.set_width(track.width() * progress);
// The gradient is sized to the *played* part, not to the whole bar, so
// the far end of it arrives at the playhead rather than at the end of
// the file. A bar that only ever shows the first fifth of its own ramp
// does not read as a gradient at all.
theme::accent_fill(painter, played, CornerRadius::same(3), 0.0);
theme::accent_fill(painter, played, radius, 0.0);
painter.circle_filled(
egui::pos2(played.right(), track.center().y),
if hovered { 8.0 } else { 6.0 },
5.0 + 3.0 * grow,
Color32::WHITE,
);
}
// Seeking needs a duration: without one there is nothing for a fraction of
// the bar to mean. Before `FileLoaded` mpv has no timeline anyway.
let Some(duration) = playback.duration.filter(|duration| *duration > 0.0) else {
let Some(duration) = duration else {
return;
};
// Chapter marks, over the bar rather than under it: an opening is two
// minutes of a twenty-four minute file, and the point of the mark is to be
// able to aim just past it.
// Chapter marks, cut into the bar rather than drawn on it: an opening is
// two minutes of a twenty-four minute file, and the point of the mark is to
// be able to aim just past it.
for chapter in playback.chapters.iter().skip(1) {
let fraction = (chapter.start / duration).clamp(0.0, 1.0) as f32;
let x = track.left() + track.width() * fraction;
painter.rect_filled(
Rect::from_min_size(
egui::pos2(x - 1.0, track.top() - 1.0),
Vec2::new(2.0, track.height() + 2.0),
egui::pos2(x - 1.5, track.top() - 1.0),
Vec2::new(3.0, track.height() + 2.0),
),
CornerRadius::ZERO,
Color32::from_black_alpha(180),
Color32::from_black_alpha(200),
);
}
// On release, not on every frame of the drag: each seek cancels outstanding
// read-ahead and refetches, so a dragged bar that seeks continuously spends
// the whole drag throwing away blocks it just paid for.
if let Some(position) = response.interact_pointer_pos()
if let Some(fraction) = pointer
&& (response.clicked() || response.drag_stopped())
{
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0);
actions.push(Action::Player(Command::SeekTo(fraction as f64 * duration)));
}
if response.hovered()
&& let Some(position) = response.hover_pos()
{
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0) as f64;
let at = fraction * duration;
if let (Some(fraction), true) = (pointer, active) {
let at = f64::from(fraction) * duration;
// The chapter under the pointer, when there is one: "42:10 · OP" tells
// a viewer what they are about to land in, which is the whole reason to
// aim at a particular part of the bar.
@@ -541,6 +590,25 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
),
None => ui::format_time(at),
};
response.clone().on_hover_text_at_pointer(hint);
bubble(
ui,
egui::pos2(track.left() + track.width() * fraction, track.top() - 10.0),
&hint,
);
}
}
/// A small label floating above a point, kept inside the window.
fn bubble(ui: &egui::Ui, anchor: egui::Pos2, text: &str) {
let painter = ui.ctx().layer_painter(egui::LayerId::new(
egui::Order::Tooltip,
egui::Id::new("seek-bubble"),
));
let galley = painter.layout_no_wrap(text.to_owned(), theme::Role::Label.font(), Color32::WHITE);
let size = galley.size() + Vec2::new(16.0, 8.0);
let screen = ui.ctx().content_rect();
let left = (anchor.x - size.x / 2.0).clamp(screen.left() + 4.0, screen.right() - size.x - 4.0);
let frame = Rect::from_min_size(egui::pos2(left, anchor.y - size.y), size);
painter.rect_filled(frame, CornerRadius::same(6), Color32::from_black_alpha(215));
painter.galley(frame.min + Vec2::new(8.0, 4.0), galley, Color32::WHITE);
}
+361
View File
@@ -0,0 +1,361 @@
//! Controls egui does not have, or has in a shape that does not fit.
//!
//! Each is painted by hand for the same reason [`super::accent_button`] is: an
//! egui widget takes one flat colour, and what makes these readable is either a
//! gradient or a state drawn as a shape rather than as a fill.
use egui::{Color32, CornerRadius, Sense, Stroke, Vec2};
use crate::theme;
/// An on/off switch with its label beside it, for settings.
///
/// Replaces egui's checkbox, whose unchecked box is drawn in the card colour —
/// on a card, which is where every setting lives, an unchecked box was not
/// there at all. A switch says "off" with a shape, not with a fill that has to
/// stand out from what is behind it.
pub fn toggle(ui: &mut egui::Ui, on: &mut bool, text: &str) -> egui::Response {
const TRACK: Vec2 = Vec2::new(34.0, 20.0);
let galley = ui.painter().layout_no_wrap(
text.to_owned(),
theme::Role::Body.font(),
Color32::PLACEHOLDER,
);
let gap = if text.is_empty() { 0.0 } else { 10.0 };
let size = Vec2::new(
TRACK.x + gap + galley.size().x,
TRACK
.y
.max(galley.size().y)
.max(ui.spacing().interact_size.y),
);
let (rect, mut response) = ui.allocate_exact_size(size, Sense::click());
if response.clicked() {
*on = !*on;
response.mark_changed();
}
response
.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, *on, text));
if ui.is_rect_visible(rect) {
let ctx = ui.ctx();
let t = ctx.animate_bool_with_time(response.id, *on, 0.14);
let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10);
let track = egui::Rect::from_min_size(
egui::pos2(rect.left(), rect.center().y - TRACK.y / 2.0),
TRACK,
);
let radius = CornerRadius::same((TRACK.y / 2.0) as u8);
let painter = ui.painter();
// Off is an outlined track; on is the accent filling it. Both are drawn
// through the switch's travel, so the fill arrives with the knob.
painter.rect_filled(track, radius, theme::card_hover());
painter.rect_stroke(
track,
radius,
Stroke::new(1.0, theme::muted().gamma_multiply(0.6 + 0.4 * hover)),
egui::StrokeKind::Inside,
);
if t > 0.0 {
let mut lit = track;
lit.set_width(TRACK.y + (TRACK.x - TRACK.y) * t);
theme::accent_fill(painter, lit, radius, 0.0);
}
let knob = egui::lerp(
(track.left() + TRACK.y / 2.0)..=(track.right() - TRACK.y / 2.0),
t,
);
let ink = theme::muted().lerp_to_gamma(Color32::WHITE, t);
painter.circle_filled(egui::pos2(knob, track.center().y), TRACK.y / 2.0 - 3.0, ink);
if response.has_focus() {
painter.rect_stroke(
track.expand(2.0),
CornerRadius::same((TRACK.y / 2.0 + 2.0) as u8),
Stroke::new(1.5, theme::accent()),
egui::StrokeKind::Outside,
);
}
let position = egui::pos2(track.right() + gap, rect.center().y - galley.size().y / 2.0);
painter.galley(position, galley, theme::text());
}
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A round mark that is either a filled tick or an empty ring: "watched".
///
/// The checkbox it replaced had the same invisible-when-off problem as the one
/// [`toggle`] replaced, and a list of episodes is the one place where the
/// unwatched state is the one that most needs to be seen.
pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response {
const SIZE: f32 = 20.0;
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
if ui.is_rect_visible(rect) {
let ctx = ui.ctx();
let t = ctx.animate_bool_with_time(response.id, watched, 0.14);
let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10);
let painter = ui.painter();
let center = rect.center();
let radius = SIZE / 2.0 - 1.0;
if t > 0.0 {
painter.circle_filled(center, radius, theme::accent().gamma_multiply(t));
}
painter.circle_stroke(
center,
radius,
Stroke::new(
1.5,
theme::muted()
.lerp_to_gamma(theme::text(), hover)
.gamma_multiply(1.0 - t),
),
);
// The tick is drawn in both states: faintly, under the pointer, while
// unwatched — so the mark says what clicking it will do.
let ink = if watched {
theme::on_accent()
} else {
theme::muted().gamma_multiply(hover)
};
if watched || hover > 0.0 {
let points = [
center + Vec2::new(-4.5, 0.2),
center + Vec2::new(-1.4, 3.3),
center + Vec2::new(4.6, -3.2),
];
painter.line(points.to_vec(), Stroke::new(1.8, ink));
}
}
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A group of settings: a heading, an optional line under it, and a card that
/// holds the rows.
///
/// The libadwaita preferences pattern, which is what `proton-drive-linux`
/// settles on: one card per subject, one row per setting, the setting's name
/// and what it does on the left and the control on the right — so the eye runs
/// down one edge for names and the other for state.
pub fn settings_group<R>(
ui: &mut egui::Ui,
heading: &str,
description: Option<&str>,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
ui.add_space(4.0);
ui.label(
theme::Role::Subhead
.rich(heading)
.strong()
.color(theme::text()),
);
if let Some(description) = description {
ui.add_space(2.0);
ui.label(crate::ui::muted(description));
}
ui.add_space(8.0);
let inner = egui::Frame::new()
.fill(theme::card())
.corner_radius(CornerRadius::same(10))
.inner_margin(egui::Margin::symmetric(16, 6))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 0.0;
add(ui)
})
.inner;
ui.add_space(26.0);
inner
}
/// One row of a [`settings_group`]: what it is on the left, the control on the
/// right. Rows after the first get a hairline above them.
pub fn settings_row<R>(
ui: &mut egui::Ui,
title: &str,
subtitle: Option<&str>,
control: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let top = ui.cursor().top();
// The first row of a card starts at its top margin; anything below that
// has a row above it to be divided from.
if top > ui.max_rect().top() + 1.0 {
let y = top;
ui.painter().hline(
ui.max_rect().x_range(),
y,
Stroke::new(1.0, theme::card_hover()),
);
}
ui.add_space(10.0);
let inner = ui
.horizontal(|ui| {
ui.set_min_height(28.0);
let text_width = (ui.available_width() * 0.62).max(160.0);
ui.vertical(|ui| {
ui.set_max_width(text_width);
ui.spacing_mut().item_spacing.y = 2.0;
ui.label(theme::Role::Body.rich(title).color(theme::text()));
if let Some(subtitle) = subtitle {
ui.add(egui::Label::new(crate::ui::muted(subtitle)).wrap());
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), control)
.inner
})
.inner;
ui.add_space(10.0);
inner
}
/// A row of choices where exactly one is on: pills on a shared track.
///
/// Returns the one clicked, if it was not already the chosen one.
pub fn segmented<T: Copy + PartialEq>(
ui: &mut egui::Ui,
current: T,
choices: &[(T, &str)],
) -> Option<T> {
let mut picked = None;
egui::Frame::new()
.fill(theme::card_hover())
.corner_radius(CornerRadius::same(8))
.inner_margin(egui::Margin::same(3))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.horizontal(|ui| {
for (value, label) in choices {
let selected = *value == current;
let text = theme::Role::Label.rich(*label).color(if selected {
theme::on_accent()
} else {
theme::text()
});
let button = egui::Button::new(text)
.corner_radius(CornerRadius::same(6))
.fill(if selected {
theme::accent()
} else {
Color32::TRANSPARENT
})
.stroke(Stroke::NONE);
if ui.add(button).clicked() && !selected {
picked = Some(*value);
}
}
});
});
picked
}
/// What a [`download_button`] shows.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DownloadGlyph {
/// Not downloaded: an arrow into a tray.
Download,
/// Coming down: a ring filling with how far it has got.
Progress(f32),
/// Stopped part way: the ring, still, with a pause mark.
Paused(f32),
/// Here: a tick.
Done,
}
/// A round icon button for one file's download.
pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Response {
const SIZE: f32 = 30.0;
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
if !ui.is_rect_visible(rect) {
return response;
}
let hover = ui
.ctx()
.animate_bool_with_time(response.id, response.hovered(), 0.10);
let painter = ui.painter();
let c = rect.center();
if hover > 0.0 {
painter.circle_filled(c, SIZE / 2.0, theme::card_hover().gamma_multiply(hover));
}
let ink = theme::muted().lerp_to_gamma(theme::text(), hover);
let stroke = Stroke::new(1.6, ink);
let ring = |painter: &egui::Painter, fraction: f32| {
let radius = 9.0;
painter.circle_stroke(c, radius, Stroke::new(2.0, theme::card_hover()));
let steps = 40;
let points: Vec<egui::Pos2> = (0..=((steps as f32 * fraction.clamp(0.0, 1.0)) as usize))
.map(|step| {
let angle = -std::f32::consts::FRAC_PI_2
+ std::f32::consts::TAU * step as f32 / steps as f32;
c + Vec2::angled(angle) * radius
})
.collect();
if points.len() > 1 {
painter.add(egui::Shape::line(points, Stroke::new(2.0, theme::accent())));
}
};
match glyph {
DownloadGlyph::Download => {
painter.line_segment([c + Vec2::new(0.0, -6.0), c + Vec2::new(0.0, 3.0)], stroke);
painter.line(
vec![
c + Vec2::new(-4.0, -1.0),
c + Vec2::new(0.0, 3.0),
c + Vec2::new(4.0, -1.0),
],
stroke,
);
painter.line_segment([c + Vec2::new(-6.0, 7.0), c + Vec2::new(6.0, 7.0)], stroke);
}
DownloadGlyph::Progress(fraction) => {
ring(painter, fraction);
// Keeps a frame coming while the ring has somewhere to go.
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(250));
if hover > 0.0 {
for x in [-2.5, 2.5] {
painter.line_segment(
[c + Vec2::new(x, -3.5), c + Vec2::new(x, 3.5)],
Stroke::new(1.8, ink.gamma_multiply(hover)),
);
}
}
}
DownloadGlyph::Paused(fraction) => {
ring(painter, fraction);
for x in [-2.5, 2.5] {
painter.line_segment(
[c + Vec2::new(x, -3.5), c + Vec2::new(x, 3.5)],
Stroke::new(1.8, ink),
);
}
}
DownloadGlyph::Done => {
painter.line(
vec![
c + Vec2::new(-5.0, 0.0),
c + Vec2::new(-1.5, 3.5),
c + Vec2::new(5.0, -3.5),
],
Stroke::new(2.0, theme::accent().lerp_to_gamma(ink, hover)),
);
}
}
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A small rounded label: a genre, a year, a fact.
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
egui::Frame::new()
.fill(theme::card_hover().gamma_multiply(0.8))
.corner_radius(CornerRadius::same(10))
.inner_margin(egui::Margin::symmetric(9, 3))
.show(ui, |ui| {
ui.label(theme::Role::Caption.rich(text).color(theme::text()));
})
.response
}
+6 -4
View File
@@ -67,8 +67,10 @@ pub struct VideoSurface {
impl VideoSurface {
/// Attach a renderer to `player` on the current OpenGL context.
///
/// `ctx` is woken whenever mpv has a new frame; without that the picture
/// would only advance when the viewer moved the mouse.
/// `wake` is called whenever mpv has a new frame, and should ask the window
/// for one. Without it the picture would only advance when the viewer moved
/// the mouse. The app's goes through [`crate::engine::Engine::wake`], which
/// knows whether the picture is on screen to be drawn at all.
///
/// # Safety
///
@@ -78,11 +80,11 @@ impl VideoSurface {
pub unsafe fn new(
player: Arc<Player>,
gl: Arc<glow::Context>,
ctx: egui::Context,
wake: impl Fn() + Send + Sync + 'static,
) -> pstr_player::Result<Self> {
// SAFETY: forwarded from this function's own contract.
let mut renderer = unsafe { VideoRenderer::new(player)? };
renderer.on_update(move || ctx.request_repaint());
renderer.on_update(wake);
Ok(Self {
gl,