mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(app): up-next card with the next still and a countdown ring, remember the window size
The up-next card shows the next episode's still with the seconds left running round it, and the still itself plays it. The window opens at the size and maximized state it closed at; position is left to the window manager.
This commit is contained in:
1 parent
c5808a5e50
commit
c60ffc7ab1
6 files changed
+252
-40
No files matched your search
@@ -426,6 +426,8 @@ pub struct App {
|
|||||||
/// Which share the crawl is on, and how far it has got.
|
/// Which share the crawl is on, and how far it has got.
|
||||||
/// Files found so far, by share name, for each share still listing.
|
/// Files found so far, by share name, for each share still listing.
|
||||||
crawl_progress: std::collections::BTreeMap<String, usize>,
|
crawl_progress: std::collections::BTreeMap<String, usize>,
|
||||||
|
/// The window's size, written at exit for the next launch.
|
||||||
|
window: crate::window::WindowMemory,
|
||||||
pub downloads: Vec<DownloadItem>,
|
pub downloads: Vec<DownloadItem>,
|
||||||
pub offline_files: std::collections::HashSet<DownloadKey>,
|
pub offline_files: std::collections::HashSet<DownloadKey>,
|
||||||
pub confirm_partial_delete: Option<DownloadKey>,
|
pub confirm_partial_delete: Option<DownloadKey>,
|
||||||
@@ -447,6 +449,7 @@ impl App {
|
|||||||
// After the engine, not before: the engine is what reads the stored
|
// After the engine, not before: the engine is what reads the stored
|
||||||
// theme, and a window that paints one frame in the default palette
|
// theme, and a window that paints one frame in the default palette
|
||||||
// before switching is a window that flashes on every launch.
|
// before switching is a window that flashes on every launch.
|
||||||
|
let window = crate::window::WindowMemory::new(dirs.window_file());
|
||||||
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
|
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
|
||||||
theme::install_font_fallbacks(&cc.egui_ctx);
|
theme::install_font_fallbacks(&cc.egui_ctx);
|
||||||
theme::apply(&cc.egui_ctx, engine.appearance());
|
theme::apply(&cc.egui_ctx, engine.appearance());
|
||||||
@@ -490,6 +493,7 @@ impl App {
|
|||||||
connecting: true,
|
connecting: true,
|
||||||
crawling: false,
|
crawling: false,
|
||||||
crawl_progress: Default::default(),
|
crawl_progress: Default::default(),
|
||||||
|
window,
|
||||||
downloads: Vec::new(),
|
downloads: Vec::new(),
|
||||||
offline_files: std::collections::HashSet::new(),
|
offline_files: std::collections::HashSet::new(),
|
||||||
confirm_partial_delete: None,
|
confirm_partial_delete: None,
|
||||||
@@ -837,10 +841,26 @@ impl App {
|
|||||||
// Nothing else causes a frame while a film plays and the mouse is
|
// Nothing else causes a frame while a film plays and the mouse is
|
||||||
// still, and a countdown that only ticks when the pointer moves is
|
// still, and a countdown that only ticks when the pointer moves is
|
||||||
// worse than none.
|
// worse than none.
|
||||||
ctx.request_repaint_after(std::time::Duration::from_millis(200));
|
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||||
|
let still = next.as_ref().and_then(|target| {
|
||||||
|
let title = self.library.get(&target.title_key)?;
|
||||||
|
let episode = title.episodes().find(|episode| {
|
||||||
|
episode.node.share_id == target.share_id && episode.node.link_id == target.link_id
|
||||||
|
})?;
|
||||||
|
ui::Art {
|
||||||
|
engine: &self.engine,
|
||||||
|
thumbs: &mut self.thumbs,
|
||||||
|
posters: &mut self.posters,
|
||||||
|
metadata: &self.metadata,
|
||||||
|
episodes: &self.episodes,
|
||||||
|
}
|
||||||
|
.still(&title.key, episode)
|
||||||
|
});
|
||||||
Some(UpNextCard {
|
Some(UpNextCard {
|
||||||
seconds: left,
|
seconds: left,
|
||||||
|
left: (left / UP_NEXT_SECONDS) as f32,
|
||||||
caption: next.map(|target| target.caption()).unwrap_or_default(),
|
caption: next.map(|target| target.caption()).unwrap_or_default(),
|
||||||
|
still,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1320,6 +1340,7 @@ impl eframe::App for App {
|
|||||||
self.pump(ctx, frame);
|
self.pump(ctx, frame);
|
||||||
self.warm_next();
|
self.warm_next();
|
||||||
self.auto_skip(ctx);
|
self.auto_skip(ctx);
|
||||||
|
self.window.observe(ctx);
|
||||||
|
|
||||||
let mut actions: Vec<Action> = Vec::new();
|
let mut actions: Vec<Action> = Vec::new();
|
||||||
|
|
||||||
@@ -1625,6 +1646,7 @@ impl eframe::App for App {
|
|||||||
/// immediately before tearing the painter down, which is the last moment
|
/// immediately before tearing the painter down, which is the last moment
|
||||||
/// that is true.
|
/// that is true.
|
||||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||||
|
self.window.save();
|
||||||
if let Some(playback) = &self.playback {
|
if let Some(playback) = &self.playback {
|
||||||
playback.stop_and_wait(std::time::Duration::from_secs(2));
|
playback.stop_and_wait(std::time::Duration::from_secs(2));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ pub mod playback;
|
|||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
pub mod video;
|
pub mod video;
|
||||||
|
pub mod window;
|
||||||
@@ -31,10 +31,12 @@ fn main() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
let dirs = AppDirs::ensure().context("resolve app directories")?;
|
let dirs = AppDirs::ensure().context("resolve app directories")?;
|
||||||
|
|
||||||
|
let window = pstr_app::window::WindowState::load(&dirs.window_file());
|
||||||
let mut options = eframe::NativeOptions {
|
let mut options = eframe::NativeOptions {
|
||||||
viewport: egui::ViewportBuilder::default()
|
viewport: egui::ViewportBuilder::default()
|
||||||
.with_title("proton-stream")
|
.with_title("proton-stream")
|
||||||
.with_inner_size([1180.0, 780.0])
|
.with_inner_size(window.map_or([1180.0, 780.0], |state| [state.width, state.height]))
|
||||||
|
.with_maximized(window.is_some_and(|state| state.maximized))
|
||||||
.with_min_inner_size([720.0, 480.0])
|
.with_min_inner_size([720.0, 480.0])
|
||||||
.with_app_id("io.narl.proton-stream"),
|
.with_app_id("io.narl.proton-stream"),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
@@ -118,8 +118,12 @@ pub struct Chrome<'a> {
|
|||||||
pub struct UpNextCard {
|
pub struct UpNextCard {
|
||||||
/// Seconds until the next episode starts.
|
/// Seconds until the next episode starts.
|
||||||
pub seconds: f64,
|
pub seconds: f64,
|
||||||
|
/// The same, as a fraction of the whole countdown, for the ring.
|
||||||
|
pub left: f32,
|
||||||
/// Which episode that is.
|
/// Which episode that is.
|
||||||
pub caption: String,
|
pub caption: String,
|
||||||
|
/// Its still, once there is one.
|
||||||
|
pub still: Option<egui::TextureHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Overlay {
|
impl Overlay {
|
||||||
@@ -468,60 +472,114 @@ fn up_next_card(
|
|||||||
bottom_margin: f32,
|
bottom_margin: f32,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
let size = Vec2::new(330.0, 104.0);
|
const STILL: Vec2 = Vec2::new(128.0, 72.0);
|
||||||
|
let size = Vec2::new(400.0, 104.0);
|
||||||
|
|
||||||
floating(ui, rect, size, bottom_margin, |ui| {
|
floating(ui, rect, size, bottom_margin, |ui| {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(Color32::from_black_alpha(215))
|
.fill(Color32::from_black_alpha(215))
|
||||||
.stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120)))
|
.stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120)))
|
||||||
.corner_radius(CornerRadius::same(8))
|
.corner_radius(CornerRadius::same(8))
|
||||||
.inner_margin(egui::Margin::symmetric(14, 12))
|
.inner_margin(egui::Margin::same(12))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(size.x - 28.0);
|
ui.set_width(size.x - 24.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label(
|
// The still, with the countdown running round it: what is
|
||||||
theme::Role::Caption
|
// coming and when, read in one glance.
|
||||||
.rich("Up next")
|
let (frame, response) = ui.allocate_exact_size(STILL, Sense::click());
|
||||||
.strong()
|
let painter = ui.painter();
|
||||||
.color(Color32::from_white_alpha(200)),
|
let radius = CornerRadius::same(6);
|
||||||
|
match &card.still {
|
||||||
|
Some(texture) => {
|
||||||
|
painter.add(
|
||||||
|
egui::epaint::RectShape::filled(frame, radius, Color32::WHITE)
|
||||||
|
.with_texture(
|
||||||
|
texture.id(),
|
||||||
|
ui::cover_uv(texture.size_vec2(), frame.size()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
painter.rect_filled(frame, radius, Color32::from_white_alpha(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
painter.rect_filled(frame, radius, Color32::from_black_alpha(90));
|
||||||
|
countdown_ring(painter, frame.center(), 22.0, card.left);
|
||||||
|
// Rounded up, so a ring that says "1" is never followed by
|
||||||
|
// a second of nothing happening.
|
||||||
|
painter.text(
|
||||||
|
frame.center(),
|
||||||
|
Align2::CENTER_CENTER,
|
||||||
|
card.seconds.ceil() as u32,
|
||||||
|
theme::Role::Body.font(),
|
||||||
|
Color32::WHITE,
|
||||||
);
|
);
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
if response.on_hover_text("Play now").clicked() {
|
||||||
// Rounded up, so a card that says "1s" is never
|
|
||||||
// followed by a second of nothing happening.
|
|
||||||
ui.label(
|
|
||||||
theme::Role::Caption
|
|
||||||
.rich(format!("in {}s", card.seconds.ceil() as u32))
|
|
||||||
.color(theme::accent()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
ui.add(
|
|
||||||
egui::Label::new(
|
|
||||||
theme::Role::Body
|
|
||||||
.rich(&card.caption)
|
|
||||||
.strong()
|
|
||||||
.color(Color32::WHITE),
|
|
||||||
)
|
|
||||||
.truncate(),
|
|
||||||
);
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
if ui::accent_button(ui, "Play now").clicked() {
|
|
||||||
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
}
|
}
|
||||||
ui.add_space(6.0);
|
|
||||||
if ui
|
ui.add_space(10.0);
|
||||||
.button("Watch till the end")
|
ui.vertical(|ui| {
|
||||||
.on_hover_text("Play this one out — nothing will be skipped")
|
ui.label(
|
||||||
.clicked()
|
theme::Role::Caption
|
||||||
{
|
.rich("Up next")
|
||||||
actions.push(Action::WatchToEnd);
|
.strong()
|
||||||
}
|
.color(Color32::from_white_alpha(200)),
|
||||||
|
);
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(
|
||||||
|
theme::Role::Body
|
||||||
|
.rich(&card.caption)
|
||||||
|
.strong()
|
||||||
|
.color(Color32::WHITE),
|
||||||
|
)
|
||||||
|
.truncate(),
|
||||||
|
);
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui::accent_button(ui, "Play now").clicked() {
|
||||||
|
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
|
}
|
||||||
|
ui.add_space(6.0);
|
||||||
|
if ui
|
||||||
|
.button("Watch till the end")
|
||||||
|
.on_hover_text("Play this one out — nothing will be skipped")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::WatchToEnd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A ring that empties clockwise from twelve o'clock as `left` runs to zero.
|
||||||
|
fn countdown_ring(painter: &egui::Painter, center: egui::Pos2, radius: f32, left: f32) {
|
||||||
|
painter.circle_stroke(
|
||||||
|
center,
|
||||||
|
radius,
|
||||||
|
egui::Stroke::new(3.0, Color32::from_white_alpha(60)),
|
||||||
|
);
|
||||||
|
let left = left.clamp(0.0, 1.0);
|
||||||
|
if left <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let steps = (64.0 * left).ceil().max(2.0) as usize;
|
||||||
|
let sweep = std::f32::consts::TAU * left;
|
||||||
|
let points: Vec<egui::Pos2> = (0..=steps)
|
||||||
|
.map(|step| {
|
||||||
|
let angle = -std::f32::consts::FRAC_PI_2 + sweep * step as f32 / steps as f32;
|
||||||
|
center + radius * Vec2::new(angle.cos(), angle.sin())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
painter.add(egui::Shape::line(
|
||||||
|
points,
|
||||||
|
egui::Stroke::new(3.0, theme::accent()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/// Put something in the bottom-right corner, `bottom_margin` above the edge.
|
/// Put something in the bottom-right corner, `bottom_margin` above the edge.
|
||||||
///
|
///
|
||||||
/// The inset matches the controls' own padding rather than being its own
|
/// The inset matches the controls' own padding rather than being its own
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//! The window's size, kept between launches.
|
||||||
|
//!
|
||||||
|
//! Size and maximized only. Position is left to the window manager: Wayland
|
||||||
|
//! does not let a client choose it, and on X11 a remembered position on a
|
||||||
|
//! monitor that has since been unplugged opens the window off screen.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// What the next launch should open at.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct WindowState {
|
||||||
|
pub width: f32,
|
||||||
|
pub height: f32,
|
||||||
|
pub maximized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The smallest size worth restoring. Anything below is a window that was
|
||||||
|
/// being dragged about when it closed, or a file someone edited by hand.
|
||||||
|
const SMALLEST: [f32; 2] = [720.0, 480.0];
|
||||||
|
|
||||||
|
impl WindowState {
|
||||||
|
/// The saved state, if there is a sensible one. A missing or broken file
|
||||||
|
/// means the default size, never an error: this is a convenience.
|
||||||
|
pub fn load(path: &Path) -> Option<Self> {
|
||||||
|
let state: Self = pstr_core::config::read_json(path).ok().flatten()?;
|
||||||
|
state.sensible().then_some(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sensible(&self) -> bool {
|
||||||
|
self.width.is_finite()
|
||||||
|
&& self.height.is_finite()
|
||||||
|
&& self.width >= SMALLEST[0]
|
||||||
|
&& self.height >= SMALLEST[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold this frame's viewport into `last`.
|
||||||
|
///
|
||||||
|
/// A fullscreen window says nothing about how big the window should be, and
|
||||||
|
/// a maximized one says nothing about how big it should be once it is not,
|
||||||
|
/// so neither changes the size kept.
|
||||||
|
pub fn observe(ctx: &egui::Context, last: Option<Self>) -> Option<Self> {
|
||||||
|
let (size, maximized, fullscreen) = ctx.input(|input| {
|
||||||
|
let viewport = input.viewport();
|
||||||
|
(
|
||||||
|
viewport.inner_rect.map(|rect| rect.size()),
|
||||||
|
viewport.maximized.unwrap_or(false),
|
||||||
|
viewport.fullscreen.unwrap_or(false),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if fullscreen {
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
let (width, height) = match (maximized, last, size) {
|
||||||
|
(true, Some(last), _) => (last.width, last.height),
|
||||||
|
(_, _, Some(size)) => (size.x, size.y),
|
||||||
|
_ => return last,
|
||||||
|
};
|
||||||
|
let state = Self {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
maximized,
|
||||||
|
};
|
||||||
|
state.sensible().then_some(state).or(last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the state when it changes, once, at exit.
|
||||||
|
pub struct WindowMemory {
|
||||||
|
path: PathBuf,
|
||||||
|
saved: Option<WindowState>,
|
||||||
|
pub current: Option<WindowState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowMemory {
|
||||||
|
pub fn new(path: PathBuf) -> Self {
|
||||||
|
let saved = WindowState::load(&path);
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
saved,
|
||||||
|
current: saved,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn observe(&mut self, ctx: &egui::Context) {
|
||||||
|
self.current = WindowState::observe(ctx, self.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) {
|
||||||
|
let Some(current) = self.current.filter(|current| Some(*current) != self.saved) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(error) = pstr_core::config::write_json(&self.path, ¤t) {
|
||||||
|
tracing::debug!("save the window size: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_window_smaller_than_the_minimum_is_not_restored() {
|
||||||
|
let path = std::env::temp_dir().join(format!("pstr-window-{}.json", std::process::id()));
|
||||||
|
let tiny = WindowState {
|
||||||
|
width: 200.0,
|
||||||
|
height: 100.0,
|
||||||
|
maximized: false,
|
||||||
|
};
|
||||||
|
pstr_core::config::write_json(&path, &tiny).unwrap();
|
||||||
|
assert_eq!(WindowState::load(&path), None);
|
||||||
|
|
||||||
|
let normal = WindowState {
|
||||||
|
width: 1400.0,
|
||||||
|
height: 900.0,
|
||||||
|
maximized: true,
|
||||||
|
};
|
||||||
|
pstr_core::config::write_json(&path, &normal).unwrap();
|
||||||
|
assert_eq!(WindowState::load(&path), Some(normal));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,11 @@ impl AppDirs {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The desktop window's size, for the next launch to open at.
|
||||||
|
pub fn window_file(&self) -> PathBuf {
|
||||||
|
self.config.join("window.json")
|
||||||
|
}
|
||||||
|
|
||||||
/// The share list.
|
/// The share list.
|
||||||
pub fn shares_file(&self) -> PathBuf {
|
pub fn shares_file(&self) -> PathBuf {
|
||||||
self.config.join("shares.json")
|
self.config.join("shares.json")
|
||||||
|
|||||||
Reference in new issue
Block a user