mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
c5adcddb1e
commit
4027ab5fb3
20 files changed
+3389
-763
No files matched your search
@@ -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,
|
||||
|
||||
+647
-91
File diff suppressed because it is too large.
Load diff
+195
-12
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -146,6 +146,103 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
|
||||
|
||||
## Fixed
|
||||
|
||||
### B60 — a failed "Add and crawl" left the crawl spinner running forever
|
||||
|
||||
**Symptom.** Pasting a link without its `#` fragment, or one already added,
|
||||
and pressing *Add and crawl* cleared the form and put "crawling" in the top
|
||||
bar, where it stayed until the app was restarted. The Refresh button never
|
||||
came back. The only sign of the real problem was a status line that had
|
||||
already faded.
|
||||
|
||||
**Cause.** `Action::AddShare` set `crawling` and cleared the form before the
|
||||
engine had answered. `Engine::add_share` reported a refused link through
|
||||
`fail`, which sends a plain `Error`, so nothing ever sent `CrawlFinished` for
|
||||
a crawl that never started.
|
||||
|
||||
**Fix.** The engine answers with `ShareAdded` or `ShareRejected`, and the
|
||||
form keeps its contents until one of them arrives. A rejection clears
|
||||
`crawling` and shows its reason inside the form. The link is also checked
|
||||
while it is typed, using `pstr_core::shares::share_token`, the same check
|
||||
`ShareStore::add` runs first, so most refusals never reach the engine.
|
||||
|
||||
**Verified.** `ui::shares::tests`: `a_link_without_its_key_is_refused_while_typing`
|
||||
and `a_share_already_added_is_refused_while_typing`.
|
||||
|
||||
### B59 — every poster fell back to initials after each match run
|
||||
|
||||
**Symptom.** After *Match the library*, and after picking or forgetting a
|
||||
match by hand, every tile in the grid dropped to its initials and faded the
|
||||
same picture back in.
|
||||
|
||||
**Cause.** `Event::Metadata` cleared the whole poster cache, because any
|
||||
title's record might have changed.
|
||||
|
||||
**Fix.** `app::changed_art` compares each title's tile-art URL before and
|
||||
after, and only those titles are forgotten. A title whose URL did not change
|
||||
keeps its texture.
|
||||
|
||||
**Verified.** `app::tests::a_match_run_that_changes_nothing_keeps_every_poster`
|
||||
and `only_titles_whose_art_moved_lose_their_poster`.
|
||||
|
||||
### B58 — "Nothing in the library yet" flashed at every launch
|
||||
|
||||
**Symptom.** For the fraction of a second it took to read the catalog, the
|
||||
library page showed the empty-library message and its *Add a share* button,
|
||||
including for a library with hundreds of titles.
|
||||
|
||||
**Cause.** `App::library` starts as `Library::default()`, which is empty, and
|
||||
the page could not tell "not read yet" from "nothing in it".
|
||||
|
||||
**Fix.** `App::loaded` is set by the first `LibraryLoaded`. Until then the
|
||||
page draws two rows of blank tiles in the shape of the grid.
|
||||
|
||||
### B57 — subtitles drawn under the player's controls
|
||||
|
||||
**Symptom.** While the controls were up, the bottom subtitle line sat
|
||||
behind the seek bar and the time labels and could not be read. The controls
|
||||
are up whenever the mouse moves, which is when someone is watching.
|
||||
|
||||
**Cause.** mpv positions subtitles against the whole window. Nothing told it
|
||||
that the bottom strip of the window was covered.
|
||||
|
||||
**Fix.** `Playback::lift_subtitles` sets mpv's `sub-pos` from the measured
|
||||
height of the controls while they are visible, and back to 100 when they
|
||||
hide. It sends a command only when the value changes. `sub-pos` rather than a
|
||||
margin, because it also moves ASS subtitles.
|
||||
|
||||
### B56 — horizontal lines across the player's scrims
|
||||
|
||||
**Symptom.** Thin dark lines ran across the picture in the fade under the
|
||||
title strip and the fade above the controls.
|
||||
|
||||
**Cause.** Each scrim was a stack of flat bands, each one pixel taller than
|
||||
its step so no gap could open between them. Every overlap was a line drawn
|
||||
at twice the alpha.
|
||||
|
||||
**Fix.** `ui::player::fade` draws one mesh with a colour per row of vertices,
|
||||
and the GPU interpolates between them. There are no overlaps left to show.
|
||||
|
||||
### B55 — every newly fetched block waited for its own fsync before playback
|
||||
|
||||
**Symptom.** Not visible as a bug. Every block the player had to wait for
|
||||
from the network, which means the first frame and the block after every
|
||||
seek, also waited for a 4 MiB write to the block cache, an fsync of it, and an
|
||||
fsync of its sidecar.
|
||||
|
||||
**Cause.** `block_at` awaited `DiskCache::put` before returning the block, and
|
||||
`write_entry` synced both files.
|
||||
|
||||
**Fix.** The put runs in a spawned task behind the read. The sidecar is no
|
||||
longer synced: the ordering the module note describes depends only on the
|
||||
block's sync, and a sidecar lost in a crash only turns a valid entry into a
|
||||
miss. Downloads now open their streams with `StreamSource::open_for_copy`,
|
||||
which reads the disk cache but never writes to it. A download no longer
|
||||
pushes the episode just watched out of the cache to store bytes that are about
|
||||
to be on disk anyway.
|
||||
|
||||
**Verified.** `stream::tests::a_fetched_block_reaches_the_disk_cache_without_the_read_waiting_for_it`
|
||||
and `a_copying_stream_reads_the_disk_cache_but_never_adds_to_it`.
|
||||
|
||||
### B54 — a window on another workspace was declared "not responding"
|
||||
|
||||
**Symptom.** On Hyprland, switching to another workspace while `proton-stream`
|
||||
|
||||
@@ -101,6 +101,24 @@ for a press, 0.10–0.12 s for a hover, 0.16 s for the tab pill's travel and 0.2
|
||||
for artwork arriving. Compose's defaults are close enough that nothing here
|
||||
overrides them.
|
||||
|
||||
## Desktop widgets
|
||||
|
||||
`pstr-app/src/ui/widgets.rs` holds the hand-painted controls. Use them instead
|
||||
of egui's own in new code:
|
||||
|
||||
- `toggle`: an on/off switch. It replaces `ui.checkbox`, whose unchecked box
|
||||
is filled with the card colour and so disappears on a card.
|
||||
- `settings_group` / `settings_row`: one card per subject, with the name and
|
||||
description on the left and the control on the right (the libadwaita
|
||||
preferences pattern `proton-drive-linux` uses).
|
||||
- `segmented`: pills on a shared track, for picking one of a few values
|
||||
(library filter, season).
|
||||
- `chip`: a small rounded fact, such as a genre.
|
||||
- `watched_mark`, `download_button`: the round icon buttons on an episode row.
|
||||
|
||||
Messages go through `ui::toast::Toasts` (via `App::note`), not a status line.
|
||||
Destructive actions ask through `ui::confirm`.
|
||||
|
||||
## Adding a token
|
||||
|
||||
Name it in the owning client first, restate it on the other side, and extend the
|
||||
|
||||
Reference in new issue
Block a user